JAVA线程安全及性能的优化笔记(三)——Volatile关键字
前期回顾:
JAVA线程安全及性能的优化笔记(二)——Synchronized关键字
本期正文:
volatile是java提供的一种同步手段,只不过它是轻量级的同步,为什么这么说?因为volatile只能保证多线程的内存可见性,不能保证多线程的执行有序性。而最彻底的同步要保证有序性和可见性,例如synchronized。任何被volatile修饰的变量,都不拷贝副本到工作内存,任何修改都能及时写在主存。因此对于valatile修饰的变量的修改,所有线程马上就能看到,但是volatile不能保证对变量的修改时有序的。什么意思呢?
publicclassVolatileTest{
publicvolatileinta;
publicvoidadd(intcount){
a=a+count;
}
}
当一个VolatileTest对象被多个线程共享,a的值不一定是正确的,因为a=a+count包含了好几部操作,而此时多个线程的执行是无序的,因为没有任何机制来保证多个线程的执行有序性和原子性。volatile存在的意义是,任何线程对a的修改,都会马上被其他线程读取到,因为直接操作主存,没有线程对工作内存和主存的同步。所以,volatile的使用场景是有限的,在有限的一些情况下可以使用volatile变量代替锁。要使用volatile变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
volatile只保证了可见性,所以volatile适合直接赋值的场景。
publicclassVolatileTest{
publicvolatileinta;
publicvoidseta(inta){
this.a=a;
}
}
在没有volatile声明时,多线程环境下,a的最终值不一定是正确的,因为this.a=a;涉及到给a赋值和将a同步回主存的步骤,这个顺序可能被打乱。如果用volatile声明了,读取主存副本到工作内存和同步a到主存的步骤,相当于是一个原子操作。所以简单来说,volatile适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。这是一种很简单的同步场景,这时候使用volatile的开销将会非常小。
站内很多人都问我,所谓线程的“工作内存”到底是个什么东西?有的人认为是线程的栈,其实这种理解是不正确的。看看JLS(java语言规范)对线程工作内存的描述,线程的workingmemory只是cpu的寄存器和告诉缓存的抽象描述。
可能很多人都觉得莫名其妙,说JVM的内存模型,怎么会扯到cpu上去呢?此时,我认为很有必要阐述下,免得很多人看的不明不白的。先抛开java虚拟机不谈,我们都知道,现在的计算机,cpu在计算的时候,并不总是从内存读取数据,它的数据读取顺序优先级是:寄存器—高速缓存—内存。线程消耗的是cpu,线程计算的时候,原始的数据来自内存,在计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写会内存。当多个线程同时读写某个内存数据时,就会产生多线程并发问题,涉及到三个特征:原子性,有序性,可见性。在《线程安全总结》这篇文章中,为了理解方便,我把原子性和有序性统一叫做“多线程执行有序性”。支持多线程的平台都会面临这种问题,运行在多线程平台上支持多线程的语言应该提供解决该问题的方案。
那么我们看看JVM,JVM是一个虚拟的计算机,它也会面临多线程并发问题,java程序运行在虚拟机平台上,java程序员不可能直接去控制底层线程对寄存器高速缓存内存之间的同步,那么java从语法层面,应该给开发人员提供一种解决方案,这个方案就是诸如synchronized,volatile,锁机制(如同步块,就绪队列,阻塞队列)等等。这些方案只是语法层面的,但我们要从本质上去理解它,不能仅仅知道一个synchronized可以保证同步就完了。在这里我说的是jvm的内存模型,是动态的,面向多线程并发的,沿袭JSL的”workingmemory”的说法,只是不想闲扯到太多底层细节,因为《线程安全总结》这篇文章在说明怎样从语法层而去理解java线程同步,知道各个关键字的使用场景。
今天有人问我,那java的线程不是有栈吗?难道栈不是工作内存吗?工作内存这四个字得放到具体的场景中区描述,方能体现它具体的意义,在描述JVM的线程同步时,工作内存指的是寄存器和高速缓存的抽象描述,具体请自行参阅JLS。上面讲的都是动态的内存模型,甚至已经超出了JVM的范围,那么JVM的内存静态存储是怎样划分的?今天还有人问我,jvm的内存模型不是有eden区吗?也不见得你提起。我跟他说,这是两个角度去看的,甚至是不同的范围,动态的线程同步的内存模型,涵盖了cpu,寄存器,高速缓存,内存;jvm的静态内存存储模型只是一种对内存的物理划分而已,它只局限在内存,而且只局限在jvm的内存。那么线程栈,eden区都仅仅在jvm内存。
说说jvm的线程栈和有个朋友反复跟我纠结的eden区吧。jvm的内存,被划分了很多的区域
1. 程序计数器
每一个java线程都有一个线程计数器来用于保存程序执行到当前方法的哪一个指令。
2. 线程栈
线程的每个方法被执行的时候,都会同时创建一个帧(Frame)
用于存储本地变脸表、操作栈、动态链接、方法出入口等信息。每个方法的调用至完成,就意味着一个帧在VM栈中的入栈至出栈的过程。如果线程请求的栈深度大于虚拟机所允许的深度,讲抛出StackOverflowError异常;如果VM栈可以动态扩展(VMSpec中允许固定长度的VM栈),当扩展时无法申请到足够内存则抛出OutOfMemoryError异常。
3. 本地方法栈
4. 堆
每个线程的栈都是该线程私有的,堆则是所有线程共享的。当我们new一个对象时,该对象就被分配到了堆中。但是堆,并不是一个简单的概念,堆区又划分了很多区域,问为什么堆划分了这么多区域,这是为了JVM的内存垃圾收集,似乎越扯越远了,扯到垃圾收集了,现在的jvm的gc都是按代收集,堆区大致被分为三大块:新生代、旧生代、持久代(虚拟的);新生代又分为eden区,s0区,s1区。新建一个对象时,基本小的对象,生命周期短的对象都会放在新生代的eden区中,eden区满时,有一个小范围的gc(minorgc),整个新生代满时,会有一个大范围的gc(majorgc),讲新生代里的部分对象转到旧生代里。
5. 方法区
其实就是永久代(PermanentGeneration),方法区中存放了每个Class的结构信息,包括常量池、字段描述、方法描述等等。VMSpace描述中对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存,也可以选择固定大小或者可扩展外,甚至可以选择不现实垃圾收集。相对来说,垃圾收集行为在这个区域是相对比较少发生的,但并不是某些描述那样永久代不会发生GC(至少对当前主流的商业JVM实现来说是如此),这里的GC主要是对常量池的回收和对类的卸载,虽然回收的”成绩”一般也比较差强人意,尤其是类卸载,条件相当苛刻
6. 常量池
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量表(constant_pooltable),用于存放编译期已可知的常量,这部分内容将在类加载后进入方法区(永久代)存放。但是Java语言并不要求常量一定只有编译期预置入Class的常量表的内容才能进入方法区常量池,运行期间也可将新内容放入常量池(最典型的String.intern()方法)