通过踩坑带你读透虚拟机的“锁粗化”

之前在学习volatile时,踩过一些坑。通过这些坑,学习了一些jvm的锁优化机制。后来在面试的过程中,被问到的概率还挺高。于是,我整理了这篇踩坑记录。

1. java多线程内存模型

在聊踩坑记录前,先要了解下java多线程内存模型。大家可通过“并发编程网”的一篇文章去学习这块知识,网址是http://ifeve.com/java-memory-model-1/。下面截取部分段落,先让大家熟悉下。

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。

局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。

本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

Java内存模型的抽象示意图如下:

通过踩坑带你读透虚拟机的“锁粗化”_第1张图片
多线程内存模型

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1、首先,线程A把本地内存A中更新过的共享变量副本刷新到主内存中去。

2、然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

上面内容可以总结如下:

1、多线程在运行时,会有主内存和工作内存的区分。
2、每个线程都有各自的工作内存,工作内存会复制一份主内存的变量副本。
3、线程其后的运行,都是修改工作内存中的变量副本。然后在某个时间,再同步到主存中。
4、这种工作机制,可能使得多个线程在同一个时刻获取到的变量值不同。

2. volatile关键字的作用

2.1. volatile关键字语义

共享变量被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

2.2. volatile关键字如何保证线程间的可见性?

1、使用volatile关键字,线程会将修改的值立即同步至主内存中

2、使用volatile关键字,线程会强制从主存中读取值。

3、所以,这就保证了某个线程修改的值,会立即被其余线程获得。

2.3. volatile关键字不保证原子性

volatile并不能代替synchronized关键字,因为它不能保证原子性。

下面给大家举个例子:

1、多个线程对变量i进行自增操作。
2、A线程从主存中获得变量i的值,为6.
3、在A获取主存的值后,B线程将运算结果7同步至主存。
4、A线程对变量i进行i++操作,然后同步至主存。主存结果依然为7。这时i++明显小于预期结果。

造成上述原因,就是因为volatile关键字不能保证自增操作的原子性。

3. 踩坑之synchronized的可见性

看完java多线程模型和volatile关键字的作用,我们正式来聊踩坑记录。

public class VolatileTest implements Runnable {
    public static String name = "dog";

    @Override
    public void run() {
        while (true) {
            System.out.println(name);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest volatileTest = new VolatileTest();
        Thread thread = new Thread(volatileTest);
        thread.start();
        // 让主线程睡一段时间,保证子线程的开启。
        Thread.sleep(5000);
        VolatileTest.name = "wangcai";
    }
}

上述的name字段,我并没有加volatile关键字。我还调用了Thread.sleep(5000);,以便让子线程先开启。

按照多线程模型的描述,子线程里的name字段应该是拷贝的变量副本“dog”。所以我在主线程修改name值为“wangcai”,并不对子线程可见。所以,按理来说,应无限循环打印“dog”。但事实上,打印结果如下:

dog
dog
dog
wangcai
wangcai
wangcai

这和上面的原理不符啊,一度让我十分困惑。后来我翻了下System.out.println的源码,发现其源码如下:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

看到源码,答案也就呼之欲出了。因为println方法添加了synchronized关键字。synchronized不仅能保证原子性,还能保证代码块里变量的可见性。所以,每次打印的值都是从主存中获取的,自然也就变为了“wangcai”。

4. 踩坑之我以为我懂了

发现上述原因后,我决定不再用System.out.println打印变量,这样就不会触发从主存中读取数据。然而我还是太天真,事情的发展就是这么曲折。

我修改的代码如下:

public class VolatileTest implements Runnable {
    public static String name = "dog";

    @Override
    public void run() {
        for (; ; ) {
            if ("wangcai".equals(name)) {
                break;
            }
            System.out.println("我不是旺财");
        }

    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest volatileTest = new VolatileTest();
        Thread thread = new Thread(volatileTest);
        thread.start();
        Thread.sleep(5000);
        VolatileTest.name = "wangcai";
    }
}

这次我仍然没有添加volatile关键字,更没有打印name变量。按理说,这次应该无限循环打印“我不是旺财”了吧。但是线程跳出循环,并停止了。这时,我已经开始对多线程模型产生动摇了。经过探索,我又知道了“锁粗化”的概念。

5. 锁粗化

下面,我们看看《深入理解java虚拟机》对锁粗化的描述:

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小-只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

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

如果虚拟机探测到有这样零碎的操作都对统一对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

将原代码生成的class文件进行反编译,得到如下代码:

public void run() {
    while(!"wangcai".equals(name)) {
        System.out.println("我不是旺财");
    }
}

于是,while循环里的System.out.println("我不是旺财");具有同步代码块,每次都对PrintStream加锁。于是,经过虚拟机的锁粗化,锁扩展到了外部,可见性也扩展到了外部。所以子线程能看见主线程对name的改变,所以会让线程跳出,并停止。

6. 守得云开见月明

public class Test implements Runnable {

    private static String name = "dog";

    @Override
    public void run() {
        while (true) {
            if ("wangcai".equals(name)) {
                System.out.println(name);
                break;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Thread thread = new Thread(test);
        thread.start();
        Thread.sleep(5000);
        Test.name = "wangcai";
    }
}

最终,将代码改成如上的样式。不加volatile,主线程对name的改变,子线程不可见。所以线程会一直循环,不退出。

加了volatile,主线程的对name的改变,子线程是可见的。所以会打出“wangcai”,并退出。

看到这里,如果你有某些疑问,我会觉得你好好研读上面的内容了。在while循环快中,我也加入了System.out.println函数,为什么没有进行锁粗化?这个依然是由反编译后的代码来决定的:

public void run() {
    while(!"wangcai".equals(name)) {
        ;
    }

    System.out.println("我是旺财");
}

通过反编译得到的源码,我们发现虚拟机对第二个代码进行了优化,是将System.out.println("我是旺财");放在循环外的。而第一个优化后的代码,是将System.out.println("我不是旺财");放在循环里的。

所以,第二个不会进行锁粗化,而第一个会进行锁粗化。

7. 总结

上面就是我在学习volatile关键字时,遇到的各种坑。但是通过踩坑,我不仅更加深入了解了volatile关键字,我也学会了虚拟机的锁粗化机制。虽然我一开始是茫然的,但是我没有放弃思考。每一次的难题,都会让我弥补知识上的短板。走出自己的知识舒适区,你才能收获成长。

通过实战,你会更为扎实地掌握所学知识点。面试的时候,通过代码向面试官阐述自己的思考过程,更能凸显出你将理论融入实践的能力,而不只是“纸上谈兵”。

后面有机会,我还会和大家分享volatile关于“防止指令重排序”的特性以及其他锁优化机制。

还是那句话,愿我们共同进步!

作者:永不言Qi
QQ: 591232672
e-mail:[email protected]
版权声明:转载请保留此链接,不得用于商业用途。
虽然我不是最优秀的程序员,但我还是想尽自己最大的努力,去分享一些学习心得。
如有错误,欢迎指正。若有幸能博得您的喜爱,欢迎关注及点赞哦。
愿我们共同进步!

你可能感兴趣的:(通过踩坑带你读透虚拟机的“锁粗化”)