理解Java并发编程:synchronized锁升级优化(偏向锁,自旋锁,重量级锁)

再谈synchronized

上一节我们从底层实现原理上分析了synchronized的性能,得出一个初步结论:synchronized底层涉及到系统调用,进而引发用户态、内核态的上下文切换,这种方式必然增加系统的性能开销。

实际上从JDK1.6开始,synchronized的实现机制有了重大变化,引入了一些优化手段来提升锁的性能,减少锁竞争导致的用户态、内核态切换。这就衍生出人们常说的偏向锁、轻量级锁、重量级锁。

这些锁的优化都是通过Java对象头中的标志位实现的。

之前的《JVM面试与调优》专栏中,我们介绍了对象有三个组成部分:对象头、实例数据、对齐填充。

其中对象头由三部分组成

1)运行时元数据(Mark Word),包括对象的哈希值、GC年龄计数、锁状态标记、线程持有的锁、偏向时间戳、偏向线程ID

2)类型指针(Instance Data),指向了方法区(元空间)中对象所属的数据类型。

3)如果创建的是数组对象,还需要保存数组的长度

锁状态标记有:无锁标记、偏向锁标记、轻量级锁标记、重量级锁标记

对于synchronized锁来说,锁的升级都是通过Mark Word中锁的标志位是否是偏向锁来进行的。synchronized关键字对应的锁都是先从偏向锁开始,随着锁竞争的升级,逐步演化成轻量级锁,最后升级到重量级锁。

可以通过启动参数进行锁的设置,比如指定JVM参数不使用偏向锁。默认情况下锁的演化会经历下列几个阶段:

无锁->偏向锁->轻量级锁->重量级锁

自旋锁可以归类为轻量级锁的一种实现方式。

偏向锁

什么是偏向锁?偏向到底是偏向谁呢?

偏向锁是针对一个线程来说的,其作用就是优化同一个线程多次获取同一把锁的情况。

当线程执行synchronized方法时,这个方法锁定的对象就会在其Mark Word中进行偏向锁标记,并且用一个字段存储该线程的ID。当这个线程再次访问同一个synchronized方法时,会检查锁定对象的Mark Word中的偏向锁标记,以及其指向的线程ID是否是当前线程。如果是,该线程无需再进入管程(Monitor),也就是无需重复申请锁,而是直接进入到方法体中执行。(在这个线程两次执行synchronized方法之间,没有其它线程去访问这个同步方法。)

因此这里的偏向其实就是指偏向了上一次访问了同步方法的线程。偏向锁的机制保证:如果同步代码总是被同一个线程所访问,那么该线程会自动获取锁,从而降低获取锁的代价。

如果是另外一个线程访问了上述synchronized方法,偏向锁便会被取消,因此偏向锁是针对同一个线程而言的。

偏向锁在JDK1.6及之后是默认开启的。可以通过设置JVM启动参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后默认直接进入轻量级锁的状态。

轻量级锁

如果第一个线程已经获取到了某个对象的锁,此时第二个线程又开始尝试获取该对象的锁。由于该对象的锁已经被第一个线程获取到,因此此时是一个偏向锁。而第二个线程在竞争锁时发现该对象的对象头中Mark Word已经是偏向锁,但是对应的线程ID并不是自己的,那么它会进行CAS(Compare And Swap)操作来获取锁。

1)获取锁成功

该线程(第二个线程)直接将对象头中Mark Word中的线程ID设置为自己的,偏向锁的标记位不变,依然保持偏向锁的状态。

2)获取锁失败

这种情况下可能是有多个线程同时在竞争该对象的锁,这时偏向锁就会被取消,进行锁的升级,升级到轻量级锁

在轻量级锁状态下,其他线程访问同步代码时会通过自旋的形式尝试获取锁,不会阻塞。

自旋锁

我们知道,阻塞或者唤醒一个Java线程涉及操作系统的用户态与内核态的上下文切换,这种状态转换是需要耗费CPU时间的。如果同步代码块中的业务逻辑非常简单,那么状态转换所消耗的时间甚至会比用户代码执行的时间还要长。

在很多场景下,同步资源锁定的时间通常都很短,为了这一小段等待时间而去切换操作系统的上下文会得不偿失。倘若物理机器是多核的,那么就能让两个及以上的线程同时执行,这样就可以让后面那个请求锁的线程不放弃CPU的执行时间而继续运行,看看拥有锁的线程是否很快就会释放锁。

为了能让线程"等等看",就需让当前线程进行自旋,如果在自旋操作完成后,前面锁定同步资源的线程已经释放了锁,那么当前线程就不需要阻塞,直接可以获取同步资源,从而避免线程切换的开销。这就是所谓的"自旋锁"。

自旋本质上是轻量级锁的一种实现方式:如果发现该对象的锁已被其它线程锁定,则当前线程不会睡眠等待,而是不停地在循环体内执行(比如死循环),当循环的条件被其他线程改变时才能进入临界区。

由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要且有效的,自旋锁的效率远高于互斥锁。

在多个线程竞争的场景下,自旋可能会失败,即依然无法获取到锁(比如持有锁的线程执行时间太长)。那么此时轻量级锁就会升级为重量级锁,无法获取到锁的线程都会进入到 Monitor(内核态)。

自旋的作用就是尽量避免线程直接从用户态立即进入到内核态,抱着""等等看再说"的态度。

如果锁的竞争异常激烈,或者线程执行的时间比较长,那么自旋达不到获取到锁的目标的话,反而会浪费CPU的资源。

重量级锁

轻量级锁也扛不住了(比如自旋失败),线程就会从用户态进入到内核态,此时锁升级为重量级锁。

这里的重量级锁(JDK1.6及之后)实际上就是JDK1.5及之前的版本中synchronized的实现方式。

几种锁的比较

偏向锁通过Mark Word中的偏向锁标记解决是否需要加锁问题,避免执行CAS操作,导致CPU空转;轻量级锁是通过CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒带来的性能开销;重量级锁直接将持有锁的线程之外的线程都阻塞。

锁的消除与锁的粗化

比如有下列代码。原本应该定义为成员变量的object对象被程序员放到了method1方法里面,作为一个局部变量。

public class Test1 {
    //private Object object = new Object();

    public void method1() {
        Object object = new Object();
        synchronized (object) {
            System.out.println("hello bird");
        }
    }
}

实际上每次调用该同步方法都会创建一个新的局部变量,根本不需要进行对象锁的竞争。但是由于有synchronized关键字,也会进行锁的竞争、释放,也有一定开销。那么JVM的即时编译器就能检测到这种问题,为我们进行执行优化。

JIT编译器在动态编译同步代码时,会使用逃逸分析技术判别程序中所使用的锁对象是否只被一个线程所使用。如果是,则JIT编译器在编译这个同步代码时不会生成synchronized关键字所标识的锁的申请与释放的机器码,从而消除锁。

再看下面的例子。在同一个方法里多次进入同步代码块,锁对象是局部变量,根据前面的分析,JIT编译器会使用锁消除技术消除锁。

public class Test2 {

    public void method1() {
        Object object = new Object();
        synchronized (object) {
            System.out.println("hello bird1");
        }
        synchronized (object) {
            System.out.println("hello bird2");
        }
        synchronized (object) {
            System.out.println("hello bird3");
        }
    }
}

如果将锁对象放到实例变量中,则不会进行锁消除。代码示例如下:

public class Test3 {
    Object object = new Object();
    public void method1() {
        synchronized (object) {
            System.out.println("hello bird1");
        }
        synchronized (object) {
            System.out.println("hello bird2");
        }
        synchronized (object) {
            System.out.println("hello bird3");
        }
    }
}

但是这个案例中明显没有必要在method1方法中进行多次加锁、释放锁。因此这种情况编译器也会进行优化。

JIT编译器在动态编译同步代码时,如果检测到前后相邻的同步代码块使用的是同一个锁对象,那么就会把这些synchronized块合并为一个大的同步代码块。这样线程在执行这些代码时就无需频繁地申请和释放锁了。只需要申请和释放一次,就可以执行完所有的同步代码块,进而提升了性能。

这就是锁粗化技术。

死锁

死锁的情况如果进行细分又有下列几个变种。

死锁:两个线程互相等待其各自持有的资源,导致两个线程都无法继续执行。

活锁:线程持续重试一个总是失败的操作,导致程序无法继续往下执行。

饿死:调度器总是先执行优先级高的线程,而总是有一个优先级高的线程存在,导致某些优先级低的线程总是得不到执行。

看一个死锁的例子,运行下列程序,一段时间后陷入死锁。

public class Test1 {
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void method1() {
        synchronized (lock1) {
            synchronized (lock2) {
                System.out.println("method1 invoked");
            }
        }
    }

    public void method2() {
        synchronized (lock2) {
            synchronized (lock1) {
                System.out.println("method2 invoked");
            }
        }
    }

    public static void main(String[] args) {
        Test1 test1 = new Test1();
        new Thread(() -> {
            while (true) {
                test1.method1();
                try {
                    Thread.sleep(120);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "myThread1").start();

        new Thread(() -> {
            while (true) {
                test1.method2();
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "myThread2").start();
    }
}

通过JVM自带的一些工具可以帮助我们检测死锁。在专栏《JVM面试与调优》中,我们已经详细介绍了这方面的知识。

比如我们可以使用GUI工具JvisualVM。检测到死锁后,可以dump线程信息。

在这里插入图片描述

可以看到两个线程阻塞,并相互等待各自lock住的资源:

在这里插入图片描述

其它的检测死锁的工具,参见《JVM面试与调优》专栏。

Lock与Synchronized的比较参见《理解Java并发编程:ReentrantLock和ReentrantReadWriteLock

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程猿薇茑

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值