Java 并发编程之 Synchronized 详解

 2023-02-19
原文作者:蒋先森 原文地址:https://jlj98.top/

声明:如果本文有错误,希望指出。

在之前的一篇博文中简单介绍了 Java 中的一些锁:Java 中各种锁
最近在极客时间上买了杨晓峰的《Java核心技术36讲》,今天看到关于标题的东西,于是想记录下自己的学习。
Synchronized 和 ReentrantLock 这两个都是可重入锁,指的是同一线程在外层函数获取锁之后,内层函数仍然可以获取该锁,且不受影响。

Synchronized 可重入测试源码

Synchronized 锁的原理

    public class SynchronizedTest {
    
        public synchronized void doSth() {
            System.out.println("doSth");
        }
    
        public void method() {
            synchronized (SynchronizedTest.class) {
                System.out.println("method");
            }
        }
    }

使用javac SynchronizedTest.javajavap -c -s -v -l SynchronizedTest 命令查看编译后的Test.class的字节码,可以很容易看出在代码块上和方法上是有区别的。代码块上使用 monitorenter/monitorexit 指令来实现的;方法上是使用 ACC_SYNCHRONIZED。详情看下面的图片,是编译后的字节码:

202212301147508101.png

在 Java6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要用户状态到内核状态的切换,所有同步操作是一个重量级操作。

Synchronized 底层原理,Synchronized 有2个队列 waitSet 和 entrtyList。

  • 当多个线程进入同步代码块时,首先进入entryList
  • 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  • 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  • 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

202212301147516982.png

锁的优化机制

现在的JVM提供三种不同的 Monitor 实现方式:

  • 偏向锁
  • 轻量级锁
  • 重量级锁

所谓锁的升级降级,就是JVM优化 synchronized 运行的机制,当JVM 检测到不同的竞争状态是,会自动切换到适合的锁的实现。

当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及到真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象声明周期最大会被一个线程锁定,使用偏向锁可以降低无竞争开销。

以 Hotspot 虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

202212301147525783.png

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

无锁

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

偏向锁

在锁竞争不强烈的情况下,通常一个线程会多次获取同一个锁,为了减少获取锁的代价引入了偏向锁,会在 Java 对象头中记录锁的 ThreadID

  • 当线程发现对相同的ThreadID存在是,判断于当前线程是否同一个线程;
  • 如果相同则不需要再次加锁、解锁;
  • 如果不是,则判断ThreadID是否存活。如果不存活,设置为无锁状态,其他线程竞争设置偏向锁;如果存活,查找ThreadID堆栈信息判断是否需要继续持有锁。如果需要持有则升级ThreadID线程的锁为轻量级锁,不需要持有则撤销锁,设置为无锁状态等待其他线程竞争。

因为偏向锁的撤销操作还是比较重的,导致进入安全点,因此在竞争比较激烈时,会影响性能,可以使用-XX:-UseBiasedLocking=false 禁用偏向锁。

轻量级锁

当偏向锁升级为轻量级锁时,其他线程尝试通过 CAS 方法设置对象头来获取锁。

  • 先在当前线程中的栈帧中设置 Lock Record,用来存储当前对象头中的 Mark word 的拷贝;
    复制 Mark word 的内容到 Lock Record,并尝试使用 CAS 将 Mark word的指针指向Lock Record
  • 如果替换成功,则获取轻量级锁;替换不成功,则自旋重试一定次数
  • 自旋一定次数或者有新的线程来竞争锁时,轻量级锁升级为重量级锁。

重量级锁

自旋是消耗CPU的,因此在自旋一段时间,或者一个线程在自旋的时候,又有新的线程来竞争,则升级为重量级锁。

重量级锁,通过monitor实现,底层实现是依赖操作系统的互斥锁实现。

需要从用户态切换到内核态,成本比较高。

上面就是所谓的锁的升级,而对于锁的降级,当JVM 进入安全点的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

202212301147534444.png

锁消除和锁粗化

锁消除

我们知道Vector的方法是由synchronized修饰的,它是线程安全的,但是我们可能仅在线程内使用,作为局部变量使用。不同线程调用时,都会创建不同的 Vector,因此,执行 add()方法的时候,使用同步操作,就是白白浪费系统资源。这时,我们可以通过编译器将其优化,将锁消除。-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks,前提是Java必须运行在server模式下,同时必须开启逃逸分析。

    public static void main(String [] args) {
        Vector<String> vector = new Vector<>();
        for (int i=0; i<10; i++) {
            vector.add(i+"");
        }
        System.out.println(vector);
    }

锁粗化

在一些情况下,我们希望多次锁的请求能够合并成一个请求,以降低最短时间内大量锁的请求、同步、释放带来的性能消耗。

比如以下情况:

    public static void test() {
        List<String> list = new ArrayList<>();
        for (int i=0; i<10; i++) {
            synchronized (Demo.class) {
                list.add(i + "");
            }
        }
        System.out.println(list);
    }
    // 代码经过锁初粗化后,将变成下面这种情况
    public static void test() {
        List<String> list = new ArrayList<>();
        synchronized (Demo.class) {
            for (int i=0; i<10; i++) {
                list.add(i + "");
            }
        }
        System.out.println(list);
    }