Java同步锁synchronized用法的最全总结

2023-03-21 17:03:06 同步 用法 最全

一、并发同步问题

  线程安全是Java并发编程中的重点,而造成线程安全问题的主要原因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。因此,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式就叫互斥锁。也就是说当一个共享数据被正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时synchronized还有另外一个重要的作用,它可以可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能)。

二、锁的简介


synchronized是Java的关键字,是一种同步锁。
  Java的内置锁:每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。
  Java内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。
  Java的对象锁和类锁:java的对象锁和类锁在锁的概念上基本上和内置锁是一致的,但是两个锁实际是有很大的区别的,对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。
  在Java中,每个对象都有一把锁和两个队列,一个队列用于挂起未获得锁的线程,一个队列用于挂起条件不满足而等待的线程。而synchronized实际上也就是一个加锁和释放锁的集成。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,其计数变为0。在任务(线程)第一次给对象加锁的时候,计数变为1。每当这个相同的任务(线程)在此对象上获得锁时,计数会递增。只有首先获得锁的任务(线程)才能继续多次获取该对象上的锁。每当任务离开一个synchronized方法,计数递减,当计数为0的时候,锁被完全释放,此时别的任务就可以使用此资源。

三、synchronized的三种应用方式

synchronized可以修饰范围的包括:方法级别,代码块级别;而实际加锁的目标包括:对象锁(普通变量,静态变量),类锁。具体分为三种应用方式:

1.修饰一个实例方法

  被修饰的方法称为实例同步方法,其作用范围是整个方法,锁定的是该方法所属的对象(即调用该方法的对象)。所有需要获得该对象锁的操作都会对该对象加锁(即访问该对象的其他同步实例方法或进入对该对象加锁的代码块)。实例同步方法的代码如下:

public synchronized void method(){
   // 具体代码
}

  当一个对象O1在不同的线程中执行这个同步方法时,他们之间会形成互斥,达到同步的效果。但是这个对象所属类的另一对象O2却能够调用这个被加了synchronized关键字的方法。 每个对象实例对应一把锁,线程只有获得对象实例的锁才能执行它的synchronized方法。 如果一个对象有多个synchronized方法,只要一个线程访问了其中的一个synchronized方法,其它线程不能同时访问这个对象中任何一个synchronized方法。但是该类的其他对象实例的 synchronized方法是不相干扰的。这种机制确保了同一时刻对于每一个对象实例,其所有声明为 synchronized 的成员方法中至多只有一个处于可执行状态(因为至多只有一个能够获得该类实例对应的锁),从而有效避免了类成员变量的访问冲突(只要所有可能访问类成员变量的方法均被声明为synchronized)。上边的示例代码等同于如下代码:

public void method(){
   synchronized(this){
       //具体代码
   }
}

  其中this指的是调用这个方法的对象,如O1。可见同步方法实质是将synchronized作用于对象引用。只有获得O1对象锁的线程,才能够调用O1的同步方法,而对O2而言,O1对象锁和它互不关联,其他线程调用O2中的相同方法时,并不会产生同步阻塞。程序也可能在这种情形下摆脱同步机制的控制,造成数据混乱。sychronized修饰方法时需要注意以下3点:

(1)synchronized关键字不能继承。


  虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,必须显式地在子类为这个方法加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。这两种方式的示例代码如下:
  手动加上synchronized修饰

class Parent{
  public synchronized void method() {}
}
class Child{
  public synchronized void method() {}
}

  在子类中调用父类同步方法
class Parent{
  public synchronized void method() {}
}
class Child{
  public synchronized void method() {}
}

(2)在定义接口方法时不能使用synchronized关键字。
(3)构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。

2.修饰一个静态方法

  被修饰的方法被称为静态同步方法,其作用的范围是整个静态方法,锁是静态方法所属的类(即Class对象)。所有需要获得该类的任意对象的锁,都会触发同步。静态同步方法的示例如下图:

上述代码中,虽然创建了SynThread类的两个对象,但是该类中的run方法调用的是静态同步方法,所以在运行过程中会同步执行。因此,synchronized作用在静态方法上时,可以防止多个线程同时访问这个类中的静态方法,它对类的所有实例对象都起作用。

3.修饰一个代码块


  被修饰的代码块称为同步语句块。synchronized的括号中必须传入一个对象(实例对象或类的Class对象)作为锁。其作用范围是大括号{}括起来的代码,锁是Synchronized括号里指定的内容。按照对象的类型可以分为类锁和对象锁。

(1)锁对象为实例对象

public void method(Object o) {   
    synchronized(o) {          
       ...  
    }
}


上述代码锁定的就是o这个对象,只要进入以该对象为锁的任何代码都会触发同步。当有一个明确的对象作为锁时,可以直接以该对象作为锁。当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁。例如:

private byte[] lock = new byte[0];

注:查看编译后的字节码:生成零长度的byte[]对象只需3条操作码,而Object lock = new Object()则需要7行操作码。因此使用特殊对象来充当锁,大大节省了系统的开销。

(2)锁对象为类的Class对象

public class Demo{
  ...
  public static void method(){
    synchronized(Demo.class){
      ...
    }
  }
}


上述代码是以Demo类的Class对象为锁,进入以该类任意实例对象为锁的代码都会触发同步,其效果类似于静态同步方法。

四、synchronized的实现原理

monitor对象
  Java中的同步代码块是使用monitorenter和monitorexit指令实现的,其中monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置。JVM保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当线程执行到monitorenter指令时,将会尝试获取锁对象所对应的monitor所有权,即尝试获取对象的锁;当线程执行monitorexit指令时,锁的monitor就会被释放。同步方法的实现与同步块略有不同,它依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。synchronized具体的实现原理详见本人另一篇文章:
深入理解Java中Synchronized的实现原理

五、Synchronized与重入锁ReentrantLock的区别

(1) 相对于ReentrantLock而言,synchronized锁是重量级锁,重量级体现在活跃性差一点。同时synchronized锁是内置锁,意味着JVM能基于synchronized锁做一些优化:比如增加锁的粒度(锁粗化)、锁消除。
(2) 在synchronized锁上阻塞的线程是不可中断的:线程A获得了synchronized锁,当线程B也去获取synchronized锁时会被阻塞。而且线程B无法被其他线程中断(不可中断的阻塞),而ReentrantLock锁能实现可中断的阻塞。
(3) synchronized锁释放是自动的,当线程执行退出synchronized锁保护的同步代码块时,会自动释放synchronized锁。而ReentrantLock需要显示地释放:即在try-finally块中释放锁。
(4) 线程在竞争synchronized锁时是非公平的:假设synchronized锁目前被线程A占有,线程B请求锁未果,被放入队列中,线程C请求锁未果,也被放入队列中,线程D也来请求锁,恰好此时线程A将锁释放了,那么线程D将跳过队列中所有的等待线程并获得这个锁。而ReentrantLock能够实现锁的公平性。
(5) synchronized锁是读写互斥并且读读也互斥,ReentrantReadWriteLock 分为读锁和写锁,而读锁可以同时被多个线程持有,适合于读多写少场景的并发。
(6) ReentrantLock锁的是代码块,synchronized还能锁方法和类。ReentrantLock可以知道线程有没有拿到锁,而synchronized不能。

六、总结

(1) synchronized如果修饰的是代码块,则根据传入内容决定锁的类型;synchronized如果修饰的是实例方法,它获取的锁是调用该方法的对象实例;synchronized如果修饰的是静态方法,它获取的锁是调用该方法所属的类,访问该类所有的同步模块都会加锁(每个对象的同步方法、类的同步方法)。
(2) synchronized同步的关键要看锁的类型
  如果是对象锁(即同步块传入对象作为锁或者实例同步方法),那么其他任意需要获得该对象锁的同步机制都会触发加锁。即线程进入一个同步实例方法,就会获得其所属对象的锁。此时,如果其他线程进入该对象其他的同步实例方法或同步代码块时就会阻塞。
  如果是类锁(即同步块传入类作为锁或者静态同步方法),那么其他任意需要获得该类锁的同步机制都会触发加锁。即线程进入一个静态同步方法,就会获得该方法所属类的锁。此时,如果其他线程再进入该类的其他同步方法(静态或非静态),或者进入获取该类锁的同步块,都会阻塞。
(3) 对象的内置锁和对象的状态之间没有内在的关联,虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定通过内置锁来保护。当获取到与对象关联的内置锁时,并不能阻止其他线程访问该对象,当某个线程获得对象的锁之后,只能阻止其他线程获得同一个锁。所以synchronized只是一个内置锁的加锁机制,当某个方法加上synchronized关键字后,就表明要获得该内置锁才能执行,并不能阻止其他线程访问不需要获得该内置锁的方法。
(4) 为什么要使用同步代码块?
  首先对程序来讲同步的部分很影响运行效率,同步所覆盖的代码越多,对效率的影响就越严重。因此我们通常尽量缩小其影响范围。所以就出现了同步代码块。只把一个方法中该同步的地方同步,并且同步代码块可以指定锁对象。
(5) 使用synchronized进入一个临界区,会获得对应的锁,退出时不管是否立即使用该对象的其他同步方法,都要立即释放锁,重新竞争获得。如何连续使用一个对象的所有同步方法,不用中途释放锁,可以创建一个线程,使用一个同步代码块,同步锁指定为该对象,然后在该代码块中可以连续调用该对象的同步方法。
(6) synchronized方法的缺陷
   a.实例同步方法锁定的是调用这个同步方法的对象。也就是说,一个对象P1在不同的线程中执行其同步方法时会产生互斥达到同步效果。但是P1对象所属类所创建的另一对象P2却可以调用这个同步方法。同步方法实质是将synchronized作用于对象引用。只有拿到P1对象锁的线程,才可以调用P1的同步方法,而对P2而言,P1这个锁与它毫不相干,程序也可能在这种情形下摆脱同步机制的控制,造成数据混乱。
  b.若将一个代码量大的方法声明为synchronized将会大大影响效率。典型地,若将线程类的方法 run() 声明为 synchronized ,由于在线程的整个生命期内它一直在运行,因此将导致它对本类任何 synchronized 方法的调用都不会成功。
 

相关文章