首页 Java同步实现原理与锁优化
文章
取消

Java同步实现原理与锁优化

我们知道,Java中通过synchronized关键字来实现同步,而同步的基础:Java中每一个对象都可以作为锁,表现在以下3个方面:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

synchronized实现原理

synchronized是基于monitor来实现的,也就是我们常说的监视器,在说它之前,我们先了解下对象头,对象在内存中的布局分为3部分:

  • 对象头:Java对象头一般占用2个字宽,但是如果对象是数组类型,则需要3个字宽,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。(1字宽在32位虚拟机中表示4个字节,也就是32bit,在64位虚拟机中表示8个字节,也就是64位)。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息。
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

当我们在Java代码中,使用new关键字创建一个对象时,JVM会再堆中创建一个instanceOopDesc对象,这个对象中包含了对象头和实例数据,而instanceOopDesc的积累oopDesc结构如下:

1
2
3
4
5
6
7
8
9
class oopDesc {
    friend class VMStructs;
    private:
    	volative markOop _mark;
        union _metadata {
			wideKlassOop _klass;
			narrowOop _compressed_klass;
        } _metadata
}

其中上面代码中_mark_metadata一起组成了对象头。这里重点介绍_mark_属性,_mark 是 markOop 类型数据,一般称它为标记字段(Mark Word),其中主要存储了对象的 hashCode、分代年龄、锁标志位,是否偏向锁等。它的存储结构如下:

锁状态25bit4bit1bit是否是偏向锁2bit锁标志位
无锁状态对象的hashcode对象分代年龄001

默认情况下,没有线程进行加锁操作,所以锁对象中的 Mark Word 处于无锁状态。但是考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 Mark Word 默认存储结构外,还有如下可能变化的结构:

image-20220825110226961

从图中可以看出,根据”锁标志位”以及”是否为偏向锁”,Java 中的锁可以分为以下几种状态:

是否偏向锁锁标志位锁状态
001无锁
101偏向锁
000轻量级锁
010重量级锁
011GC标记

在 Java 6 之前,并没有轻量级锁和偏向锁,只有重量级锁,也就是通常所说 synchronized 的对象锁,锁标志位为 10。从图中的描述可以看出:当锁是重量级锁时,对象头中 Mark Word 会用 30 bit 来指向一个“互斥量”,而这个互斥量就是 Monitor。

在Java虚拟机中,ObjectMonitor是Monitor的具体实现,因此Java中的每一个对象都有一个对应的ObjectMonitor,这也是Java中所有对象都可以作为锁的原因。

ObjectMonitor类中有几个比较重要的属性:

  • _owner:指向持有ObjectMonitor对象的线程
  • _WaitSet:存放处于wait状态的线程队列
  • _EntryList:存放处于等待锁block状态的线程队列
  • _recursions:锁的重入次数
  • _count:用来记录该线程获取锁的次数

当多个线程同时访问一段同步代码时,首先会进入 _EntryList 队列中,当某个线程通过竞争获取到对象的 monitor 后,monitor 会把 _owner 变量设置为当前线程,同时 monitor 中的计数器 _count 加 1,即获得对象锁。 若持有 monitor 的线程调用 wait() 方法,将释放当前持有的 monitor,_owner 变量恢复为 null, _count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。

synchronized具体实现

1、同步代码块采用monitorenter、monitorexit指令显式的实现。

2、同步方法则使用ACC_SYNCHRONIZED标记符隐式的实现。

示例如下:

1
2
3
4
5
6
7
8
9
10
public class Test {
    public synchronized void method1() {
        System.out.println("synchronized method");
    }
    public void method2() {
        synchronized(this) {
            System.out.println("synchronized block");
        }
    }
}

将上面代码采用javac编译成字节码指令后,使用javap -v查看字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public synchronized void method1();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String synchronized method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
            
public void method2();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #5                  // String synchronized block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 8: 12
        line 9: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class Test, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}

上面可以看出method1的flag多了一个ACC_SYNCHRONIZED标志,而method2方法在第22行、27行、31行,分别有1个monitorenter和2个monitorexit指令,而多出来的这个退出指令也是为了保证在异常时也能够正常释放锁。

每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,获取规则如下:

  • 如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者。
  • 如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁。
  • 如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。

只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor。

锁升级

JavaSE1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。此时锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级(膨胀)。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

所谓偏向锁,就是锁会偏向于已经占有锁的线程,这种锁容易出现在没有竞争的情况下。在没有竞争的情况下,当第 1 个线程第一次访问同步块时,会先检测对象头 Mark Word 中的标志位(Tag)是否为 01,以此来判断此时对象锁是否处于无锁状态或者偏向锁状态(匿名偏向锁)。当获得锁的线程再次进入同步块时,不需要做同步处理。这也是锁默认的状态,线程一旦获取了这把锁,就会把自己的线程 ID 写到 Mark Word 中,在其他线程来获取这把锁之前,该线程都处于偏向锁状态。 JVM使用:-XX:+UseBiasedLocking参数启用偏向锁(1.6以后默认启用)。但是需要注意一点的是:在竞争激烈的场景下,开启偏向锁会增加系统的负担。 当下一个线程参与到偏向锁竞争时,会先判断 Mark Word 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,则会立即撤销偏向锁,升级为轻量级锁。

轻量级锁

当偏向锁失效时,线程间会竞争轻量级锁。参与竞争的每个线程,会在自己的线程栈中生成一个 Lock Record ( LR ),然后每个线程通过 CAS(自旋)的操作将锁对象头中的 Mark Word 设置为指向自己的 LR 指针,哪个线程设置成功,就意味着哪个线程获得锁,并将标志位(Tag)改为00。在这种情况下,JVM 不会依赖内核进行线程调度。若获取不到,自旋操作后,升级为重量级锁。

自旋锁

当线程竞争轻量级锁失败后,此时并不会立即在操作系统层面挂起,而是做一些空循环,也就是所谓的自旋锁。系统希望在自旋的过程中可以获得锁。如果若干次之后还未获得到,则进入阻塞状态,加重量级锁。所以说轻量级锁适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

重量级锁

这里就是指上面锁竞争的最后一道关卡,进入了线程挂起的状态。此时如果获取不到锁,线程就会被挂起,进入到操作系统内核态,等待操作系统的调度,然后再映射回用户态。系统调用是昂贵的,重量级锁的名称也由此而来。

以上便是锁膨胀的过程

锁消除

锁消除相关联的一项技术就是基于Java对象的逃逸分析。就是分析某一个变量会不会在一个作用域外部引用。例如我们在方法内部的变量中,使用了StringBuffer sb处理了字符串。那么在实际的执行过程中,虚拟机会消除StringBuffer内部的同步控制,意思就是这个过程不需要加锁。还有一点需要注意的是String str = a + b; 这种字符串拼接代码,在JDK1.5以前会转化为StringBuffer的append()处理;在1.5及以后版本,会转化为StringBuilder的append()处理。

可以使用-XX:+DoEscapeAnalysis打开逃逸分析;使用-XX:+EliminateLocks打开锁消除。

锁优化

另外Java针对高并发场景的锁优化,做了很多实现。

减少锁的持有时间

比如避免给整个方法加锁,这个常见就是我们可以从同步方法调整为同步代码块,如下面从method1到method2的优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
    public synchronized void method1() {
        // 其它代码
        // 同步代码
        // 其它代码
    }
    public void method2() {
        // 其它代码
        synchronized(this) {
            // 其它代码
        }
        // 其它代码
    }
}

减小锁的粒度

将大对象,拆成小对象,大大增加并行度,降低锁竞争。如此一来偏向锁,轻量级锁成功率提高.。一个简单的例子就是 jdk 内置的 ConcurrentHashMap 与 SynchronizedMap。Collections.synchronizedMap 其本质是在读写 map 操作上都加了锁,在高并发下性能一般。ConcurrentHashMap 内部使用分区 Segment 来表示不同的部分,每个分区其实就是一个小的 hashtable,各自有自己的锁。

只要多个修改发生在不同的分区,他们就可以并发的进行。把一个整体分成了 16 个 Segment,最高支持 16 个线程并发修改。此外代码中还运用了很多 volatile 声明共享变量,第一时间获取修改的内容,性能较好。

读写分离锁替代独占锁

顾名思义,用 ReentrantReadWriteLock 将读写的锁分离开来,尤其在读多写少的场合,可以有效提升系统的并发能力。

  • 读-读不互斥:读读之间不阻塞,因为读操作不影响多线程并发访问,不会造成脏数据,不一致等情况。
  • 读-写互斥:读阻塞写,写也会阻塞读。
  • 写-写互斥:写写阻塞。

锁分离

在读写锁的思想上做进一步的延伸,根据不同的功能拆分不同的锁,进行有效的锁分离。一个典型的示例便是 LinkedBlockingQueue,在它内部,take 和 put 操作本身是隔离的。有若干个元素的时候,一个在 queue 的头部操作,一个在 queue 的尾部操作,因此分别持有一把独立的锁。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短。

即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。

而凡事都有一个度,如果对同一个锁不停的进行请求同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

一个极端的例子如下,在一个循环中不停的请求同一个锁。

1
2
3
4
5
6
7
8
9
10
11
12
Object lock = new Object();
for(int i = 0; i < 1000; i++){
    synchronized(lock){

    }
}
// 优化后
synchronized(lock){
    for(int i = 0;i < 1000; i++){

    }
}

锁粗化与减少锁的持有时间,两者是截然相反的,需要在实际应用中根据不同的场合权衡使用。

ThreadLocal

除了控制有限资源访问外,我们还可以增加资源来保证对象线程安全。

对于一些线程不安全的对象,例如 SimpleDateFormat,与其加锁让 100 个线程来竞争获取。

不如准备 100 个 SimpleDateFormat,每个线程各自为营,很快的完成 format 工作。

无锁/乐观锁/自旋锁

与锁相比,使用 CAS 操作,由于其非阻塞性,因此不存在死锁问题,同时线程之间的相互影响,也远小于锁的方式。自旋锁优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。

本文由作者按照 CC BY 4.0 进行授权

Java内存模型JMM与多线程

Android Framework源码分析-Activity的启动过程