深入理解synchronized关键字

hresh 594 0

深入理解synchronized关键字

前言

线程安全

在并发编程学习过程中,我们应该都听过“线程安全”这个名称,对于这一概念,我们知道它可以解决并发编程不安全的问题,也有一个简单的印象:“代码在并发环境下,可以安全地被多个线程使用,这就是线程安全“。上述关于“线程安全”的认识大致是对的,我们来看看别人是如何定义“线程安全”的。

《Java并发编程实战(Java Concurrency In Practice) 》 的作者 Brian Goetz 为“线程安全”做出了一个比较恰当的定义: “当多个线程同时访问一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或者在调用方进行任何其他的协调操作, 调用这个对象的行为都可以获得正确的结果, 那就称这个对象是线程安全的。 ”

synchronized实现线程安全

互斥同步(Mutual Exclusion & Synchronization) 是一种最常见也是最主要的并发正确性保障手段。 同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候) 线程使用。 而互斥是实现同步的一种手段, 临界区(Critical Section) 、 互斥量(Mutex) 和信号量(Semaphore) 都是常见的互斥实现方式。

在 Java 里面, 最基本的互斥同步手段就是 synchronized 关键字, 这是一种块结构(Block Structured) 的同步语法。它解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。 在《Java并发编程Bug的源头》一文中介绍的三个问题,synchronized 关键字都可以顺利地应对,即保证了原子性、可见性和有序性,相较于 volatile 关键字功能更加强大,本文将对该关键字进行深入学习。

synchronized实现方式

synchronized 关键字来实现加锁,注意要搞清楚被锁的资源(操作代码块)和锁,锁可以分为三种:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized 关键字加到静态方法和 synchronized(class)代码块上都是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) ,因为 JVM 中,字符串常量池具有缓存功能

synchronized作用于实例方法

所谓的实例对象锁就是用 synchronized 修饰实例对象中的实例方法,注意是实例方法不包括静态方法,如下

public class SynchronizedAddTest {

  static int count = 0;

  public static void main(String[] args) {
    SynchronizedAddTest obj = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj.add();
    }, "A");

    Thread t2 = new Thread(() -> {
      obj.add();
    }, "B");

    t1.start();
    t2.start();

    try {
      t1.join();
      t2.join();

      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }

  }

  public synchronized void add() {
    for (int i = 0; i < 100000; i++) {
      count++;
    }
  }
}

上述代码在深入学习volatile一文中有过类似案例,不过使用 volatile 关键字是无法保证原子性的,所以最终的结果可能不是 20万,而且每次运行结果都不一样,总是小于 20万。而加了 synchronized 关键字后,一定可以保证结果为 20万。

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量 count 的值为10,

线程A对变量进行自增操作,线程A先读取了变量 count 的原始值,然后线程A被阻塞了(可能存在的情况);

然后线程B对变量进行自增操作,线程B也去读取变量 count 的原始值,由于线程A只是对变量 count 进行读取操作,而没有对变量进行修改操作,所以主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程A接着进行加1操作,由于已经读取了 count 的值,注意此时在线程A的工作内存中 count 的值仍然为10,所以线程A对 count 进行加1操作后 count 的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

加了 synchronized 关键字后,上述情况就会有所变化:

假如某个时刻变量 count 的值为10,

线程A对变量进行自增操作,首先要获取实例对象的锁,然后读值,进行加1操作,即使此时线程A被阻塞了,但不会主动释放锁。

那么线程B想要进行自增操作,就无法获取该实例对象的锁,所以就无法进行自增操作,只能等待线程A执行完毕,释放锁后,线程B才可以获取锁,然后进行自增操作。

线程A在释放锁之前是会将更新后的值写入到主存中,所以线程B就可以拿到最新的 count 值。

需要注意的是:如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象。

SynchronizedAddTest obj1 = new SynchronizedAddTest();
SynchronizedAddTest obj2 = new SynchronizedAddTest();
Thread t1 = new Thread(() -> {
  obj1.add();
}, "A");

Thread t2 = new Thread(() -> {
  obj2.add();
}, "B");

//输出结果为:
main线程输出结果为==>111538

虽然我们使用 synchronized 修饰了 increase 方法,但却 new 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。

解决这种困境的的方式是将 synchronized 作用于静态的 add 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将 synchronized 作用于静态的 add 方法。

synchronized作用于静态方法

当 synchronized 作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class 对象锁可以控制静态成员的并发操作。

public class SynchronizedAddTest {

  static int count = 0;

  public static void main(String[] args) {
    SynchronizedAddTest obj1 = new SynchronizedAddTest();
    SynchronizedAddTest obj2 = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj1.add();
    }, "A");

    Thread t2 = new Thread(() -> {
      obj2.add();
    }, "B");

    t1.start();
    t2.start();

    try {
      t1.join();
      t2.join();

      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }

  }

  public static synchronized void add() {
    for (int i = 0; i < 100000; i++) {
      count++;
    }
  }
}
//
main线程输出结果为==>200000

需要注意的是,如果一个线程A调用一个实例对象的非 static synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,这是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类对象的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

synchronized同步代码块

除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块。某些情况下,方法体中只有部分代码存在并发安全隐患,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:

public class SynchronizedAddTest {

  static int count = 0;

  public static void main(String[] args) {
    SynchronizedAddTest obj = new SynchronizedAddTest();
    Thread t1 = new Thread(() -> {
      obj.add();
    }, "A");

    Thread t2 = new Thread(() -> {
      obj.add();
    }, "B");

    t1.start();
    t2.start();

    try {
      t1.join();
      t2.join();

      System.out.println("main线程输出结果为==>" + count);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }

  }

  public void add() {
    synchronized (this) {
      for (int i = 0; i < 100000; i++) {
        count++;
      }
    }
  }
}
//main线程输出结果为200000

同步代码块与作用在实例方法上的加锁对象是一样的,都是对当前线程持有的 instance 实例对象进行加锁,多个线程调用同一个实例对象的方法,需要先获取对象锁,一旦某个线程持有该对象锁,其他线程就必须等待。

了解完 synchronized 的基本含义及其使用方式后,下面我们将进一步深入理解 synchronized 的底层实现原理。

synchronized底层原理

在《Happens-Before规则详解》一文中讲解 Happens-Before 规则时,其中有个规则叫做「管程锁定规则」,具体定义为:synchronized 是 Java 对管程的实现,管程中的锁在 Java 里是隐式实现的,隐式加锁、释放锁,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

再往深处来说,Java 虚拟机中的同步(synchronization)是基于进入和退出管程(Monitor)对象实现的, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块),还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念 Java对象头,这对深入理解synchronized 实现原理非常关键。

Java对象头与Monitor

synchronized 用的锁是存在 Java 对象头里的。在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

对象头包括两部分信息:标记字段(Mark Word)和类型指针(Class Metadata Address),如果对象是一个数组,还需要一块用于记录数组长度的数据。

深入理解synchronized关键字

其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等,32位 JVM 的 Mark Word 的默认存储结构如下图所示:

深入理解synchronized关键字

我们可以在 JVM 源码 (hotspot/share/oops/markOop.hpp) 中看到对象头中存储内容的定义

 public:
  // Constants
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };

在该文件中关于标记字段的结构有如下示例:

//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

字段含义如下:

  • hash: 对象的哈希码
  • age: 对象的分代年龄
  • biased_lock : 偏向锁标识位
  • lock: 锁状态标识位
  • JavaThread* : 持有偏向锁的线程 ID
  • epoch: 偏向时间戳

markOop中不同的锁标识位,代表着不同的锁状态:

深入理解synchronized关键字

不同的锁状态,存储着不同的数据:

深入理解synchronized关键字

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。重量级锁的锁标识位为10,其中指针指向的是 monitor 对象的起始地址。每个 Java 对象都关联着一个 monitor,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁,或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机(HotSpot)中,monitor 是由ObjectMonitor 实现的,其主要数据结构如下(位于HotSpot虚拟机源码 ObjectMonitor.hpp文件,C++实现的)

// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
  _header       = NULL;
  _count        = 0;
  _waiters      = 0,
  _recursions   = 0;        //记录嵌套(递归)加锁的次数,最外层的锁的_recursions属性为0
  _object       = NULL;
  _owner        = NULL; //占用当前锁的线程
  _WaitSet      = NULL; //等待集合,处于wait状态的线程,会被加入到_WaitSet,配合 wait和Notify/notifyALl 使用
  _WaitSetLock  = 0 ;       //保护等待队列,简单的自旋锁
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;
  FreeNext      = NULL ;
  _EntryList    = NULL ;    //阻塞队列,处于等待锁block状态的线程,会被加入到该列表,配合synchronized锁进行使用
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
}

ObjectMonitor 中有两个队列,WaitSet 和 _EntryList。

ObjectMonitor 对象中有多个属性,这里我们介绍几个重点的字段。

```c++
protected:
ObjectWaiter * volatile _WaitSet; // LL of threads wait()ing on the monitor
protected:
ObjectWaiter * volatile _EntryList ; // Threads blocked on entry or reentry.
protected: // protected for jvmtiRawMonitor
void * volatile _owner; // pointer to owning thread OR BasicLock

<pre><code class="line-numbers">WaitSet 用来保存 ObjectWaiter 对象列表( 每个 wait 状态的线程都会被封装成 ObjectWaiter对象),EntryList 用来保存处于 block 状态的线程封装的 ObjectWaiter对象,owner 指向持有 ObjectMonitor 对象的线程。

这里简单描述一下 synchronized(重量级锁)的加锁和解锁过程:当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,owner 变量会设置为当前线程,同时 monitor 中的计数器 count 加1。若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 复位 count 变量的值,以便其他线程进入获取 monitor。

关于上述逻辑可以在 hotspot\src\share\vm\runtime\objectMonitor.cpp 文件中获取,具体可以阅读 enter(TRAPS) 方法,感兴趣的朋友可以可以阅读一下这篇[文章](https://blog.csdn.net/qq_31865983/article/details/105111026)。

由此可知,monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,这也是为什么 Java 中任意对象可以作为锁的原因。

### synchronized代码块底层原理

synchronized 同步语句块的情况

```java
public class SynchronizedDemo {

public void method(){
synchronized (this){
System.out.println("synchronized code");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -verbose SynchronizedDemo.class

深入理解synchronized关键字

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。这里提到的锁计数器,即上文提到的 count 变量。另外还有锁重入的情况,当线程获取该对象的锁后,在未释放锁之前,可以直接进行代码调用,不需要等待。具体到代码实现,就是重入时重入计数器会加1,这块逻辑在 enter()方法中有描述。

值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都要执行其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

synchronized方法底层原理

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor , 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。

public class SynchronizedDemo {

    public synchronized void foo(){
        System.out.println("synchronized method");
    }
}

深入理解synchronized关键字

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

JVM对synchronized的优化

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

自旋锁与自适应自旋

synchronized 属于重量级锁,本质上是通过互斥同步来实现线程安全,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

总结来说:synchronized 会阻塞其他线程的执行,另外线程切换开销大,导致性能低下。

Java 虚拟机开发人员为此进行了如下优化:如果机器能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

自旋锁在 JDK 1.4.2 中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在 JDK 6中就已经改为默认开启了。 但自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次, 用户也可以使用参数-XX:PreBlockSpin 来自行更改。

深入理解synchronized关键字

自旋次数不管是采用默认值还是自己设置,并不能应对所有的锁情况,无法起到好的效果。因此 JDK6 引入了自适应的自旋锁。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

锁消除

锁消除是指 JIT 编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。 具体来说就是,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks(默认开启)可以开启同步消除。

来看一个经典案例:

public String concatString(String s1, String s2, String s3)
{
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}

而 StringBuffer 的 append 方法定义如下:

public synchronized StringBuffer append(String str) {
  toStringCache = null;
  super.append(str);
  return this;
}

也就是说在 concatString()方法中涉及了同步操作。但是可以观察到 sb 对象它的作用域被限制在方法的内部,也就是 sb 对象不会“逃逸”出去,其他线程无法访问。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

在上文讲述 synchronized 的三种实现方式时,推荐我们使用同步代码块,将可能出现线程安全问题的代码圈起来即可,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

以上原则大多数情况下是正确的,但特殊情况特别对待,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如下案例,我们暂时不考虑锁消除的情况,连续的 append()方法就属于这种情况,会对同一个对象反复加锁。

public String concat(String s1, String s2, String s3)
{
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}

所以我们可以将把加锁同步的范围扩展(粗化) 到整个操作序列的外部,如下代码所示:

public String concat(String s1, String s2, String s3) {
  StringBuilder sb = new StringBuilder();
  synchronized (this){
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
  }
  return sb.toString();
}

无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

深入理解synchronized关键字

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。无锁本质上是基于 CAS 原理实现的,后续我们会详细介绍这一原理。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当某个线程第一次访问同步代码块并获取锁时,使用 CAS 操作在 Mark Word 里存储锁偏向的线程 ID,将会把对象头中的标志位设置为“01”、 把偏向模式设置为“1”, 表示进入偏向模式。持有偏向锁的线程之后再进入和退出同步块时,不需要再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

一旦有其他线程尝试竞争偏向锁时,持有偏向锁的线程会释放锁,它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在 JDK 6及以后的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

轻量级锁升级为重量级锁的场景:

1、若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

2、另外在轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

整体的锁状态升级流程如下:

深入理解synchronized关键字

锁的差异

深入理解synchronized关键字

synchronized其他特性

synchronized是非公平锁

首先我们来看一下公平锁与非公平锁的描述,如下图所示:

深入理解synchronized关键字

而 synchronized 关键字则是非公平锁,加上 synchronized 是依赖于 JVM 实现的,具体实现我们无法查看,这里就不探究了。未来学习 Lock 锁时会深入源码来学习公平锁与非公平锁的实现。

synchronized的可重入性

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

在上面的代码中,类中的两个方法都是被内置锁 synchronized 修饰的,doSomething()方法中调用 doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用 doOthers()时可以直接获得当前对象的锁,进入 doOthers()进行操作。

注意由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器(_recursions)仍会加1。

线程中断与synchronized

Java 的中断是一种协作机制。也就是说调用线程对象的 interrupt 方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个 boolean 的中断状态(这个状态不在Thread的属性上),interrupt 方法仅仅只是将该状态置为 true。

关于中断的讲解,推荐阅读Thread的中断机制(interrupt)Java Thread的interrupt详解,这两篇文章讲的非常详细。

这里只介绍一下中断与 synchronized 之间的关联:synchronized 在获锁的过程中是不能被中断的,意思是说如果产生了死锁,则不可能被中断。

public class SynchronizedAndInterrupt implements Runnable {

  boolean stop = false;

  public SynchronizedAndInterrupt() {
    new Thread(() -> foo(),"thread-A").start();
  }

  public synchronized void foo() {
    while (!stop) {
      System.out.println(Thread.currentThread() + "" + System.currentTimeMillis());
      // 让该循环持续一段时间,使上面的话打印次数少点
      long time = System.currentTimeMillis();
      while ((System.currentTimeMillis() - time < 1000)) {
      }
    }
  }

  @Override
  public void run() {
    while (!stop) {
      if (Thread.interrupted()) {
        stop = true;
        break;
      } else {
        foo();
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    SynchronizedAndInterrupt obj = new SynchronizedAndInterrupt();
    Thread thread = new Thread(obj);
    thread.start();
    Thread.sleep(5000);
    System.out.println(thread.getName() + " Interrupting thread...");
    thread.interrupt();
    System.out.println(thread.getName() + " 线程" + thread + "是否中断:" + thread.isInterrupted());
  }
}

执行结果如下所示,并且会一直打印日志。

Thread[thread-A,5,main]1658298529537
Thread[thread-A,5,main]1658298530537
Thread[thread-A,5,main]1658298531537
Thread[thread-A,5,main]1658298532537
Thread[thread-A,5,main]1658298533537
Thread[thread-A,5,main]1658298534537
Thread-0 Interrupting thread...
Thread-0 线程Thread[Thread-0,5,main]是否中断:true
Thread[thread-A,5,main]1658298535537
Thread[thread-A,5,main]1658298536537
......

根据结果可知:当我们在 SynchronizedAndInterrupt 构造函数中创建一个新线程并启动获取调用 foo()获取到当前实例锁,由于 SynchronizedAndInterrupt 自身也是线程,启动后在其 run方法中也调用了 foo(),但由于对象锁被其他线程占用,导致线程 Thread-0 只能等到锁,此时我们调用了thread.interrupt();但并不能中断线程 thread-A。

等待唤醒机制

线程通信

多线程意味着线程间存在交互问题,各线程在执行过程中会相互通信。所谓通信就是指相互交换一些数据或者发送一些控制指令,比如一个线程给另一个暂停执行的线程发送一个恢复执行的指令。

线程通信需要考虑很多问题:共享变量的内存可见性问题、原子性问题以及指令重排序问题。Java提供了volatile 和 synchronized 的同步手段来保证通信内容的正确性。

wait 和 notify/notifyAll 就是线程通信的一种方式。

wait、notify/notifyAll方法

当一个线程获取到锁之后,因为某些原因可能暂时释放锁,然后该线程就会进入等待队列里等待去,等到其他线程通知某个线程把这个条件完成后,就通知等待队列里的线程他们等待的条件满足了,可以继续运行了。

当一个线程获取到锁之后,如果因为某个条件不满足,需要主动让出锁,该线程就会被放到一个等待队列里等待去,等到某个线程把这个条件完成后,就通知等待队列里的线程它们等待的条件满足了,可以继续运行了。

注意:Java 规定每个锁(Monitor)对应一个等待队列(_WaitSet)。

synchronized 可以与 wait 和 notify/notifyAll 结合使用,不过在使用这三个方法时,必须处于 synchronized 代码块或者 synchronized方法中,否则就会抛出 IllegalMonitorStateException 异常。

原因如下:因为调用这几个方法前必须拿到当前对象的监视器 monitor 对象,也就是说 notify/notifyAll 和 wait 方法依赖于 monitor 对象,在前面的分析中,我们知道 monitor 存在于对象头的 Mark Word 中(存储monitor引用指针),而 synchronized 关键字可以获取 monitor。

另外,这也是 wait 和 notify/notifyAll 是顶级对象 Object 的方法的原因,在 monitor 对象中有对应的方法实现。

注意事项

1、与 sleep 方法不同的是,wait 方法调用完成后,线程将被暂停,但 wait 方法将会释放当前持有的监视器锁(monitor),直到有线程调用 notify/notifyAll 方法后方能继续执行;而 sleep 方法只让线程休眠并不释放锁。

2、notify/notifyAll 方法调用后,并不会马上释放监视器锁,而是在被 synchronized 修饰的代码或方法执行结束后才自动释放锁。

3、wait()必须在同步(Synchronized)方法/代码块中调用,因为调用 wait()就是释放锁,释放锁的前提是必须要先获得锁,先获得锁才能释放锁。

生产消费者案例

/**
 * @author hresh
 * @date 2020/2/16 21:19
 * @description
 * 线程之间的通信问题:生产者和消费者问题
 * 传统解决方法,Sychronized,wait,notify三者结合使用
 */
public class A {

    public static void main(String[] args) {
        Data data = new Data();

        new Thread(()->{
            for (int i=0;i<20;i++){
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"A").start();

        new Thread(()->{
            for (int i=0;i<10;i++){
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"B").start();

        new Thread(()->{
            for (int i=0;i<10;i++){
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"C").start();
    }
}

class Data {
    private int num = 0;

    //判断等待,业务,通知
    public synchronized void increment() throws InterruptedException {
        //注意这里使用的是while判断,而非if判断,防止虚假唤醒
        while (num != 0){
            //等待
            this.wait();
        }
        num++;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
        //通知其他线程,我+1完毕了
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        while (num == 0){
            this.wait();
        }
        num--;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
        //通知其他线程,我-1完毕了
        this.notifyAll();
    }
}

关于 wait 方法的判断,必须使用 while 条件,官方文档对此是这样描述的。

深入理解synchronized关键字

参考文献

《Java并发编程的艺术》

《深入理解Java虚拟机》

不可不说的Java“锁”事

深入理解Java并发之synchronized实现原理

发表评论 取消回复
表情 图片 链接 代码

分享