踩内存是什么意思啊_通过踩坑带你读透虚拟机的“锁粗化”

6c2a7541decff900f85899b54c0f8f1c.png

之前在学习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内存模型的抽象示意图如下:

2dc96d69cf408f78f2b802095758fa2f.png

多线程内存模型

从上图来看,线程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关键字不能保证自增操作的原子性。

bb8ff498659672949bc7c414c9fce33c.png

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”。但事实上,打印结果如下:

dogdogdogwangcaiwangcaiwangcai

这和上面的原理不符啊,一度让我十分困惑。后来我翻了下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关于“防止指令重排序”的特性以及其他锁优化机制。

cafe4b77f68269148d72a40041e1c521.png
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值