Volatile关键字
Volatile关键字有两个作用
-
线程可见
-
防止指令重排序
线程可见性
线程会将操作的变量都做一份复制,保存到本地内存中去,然后根据这份复制去进行操作的
多线程出现的问题在于各个线程之间不清楚操作的变量是否发生修改,因为都是根据据本地内存的副本去进行的,所以会发生,而volatie关键字实现了线程之间对变量的通信,让线程之间可以看到指定变量的情况
防止指令重排序
Java程序在new一个实例的时候(注意不是类加载的过程),步骤如下
-
在堆中划分内存
-
给对象属性加上默认值
-
给对象属性赋上初始值
-
让栈中的变量指向堆中为对象划分的内存,也就是引用赋值
前面两条是没有问题的,但后面两条是可能会发生重排序的
当给对象属性赋上初始值的时候,如果这个操作耗时比较旧,CPU会先去执行后面的引用赋值的操作,这就是发生了指令重排序
指令重排序对于单线程来说是没有问题的,但对于多线程来说就会产生问题
这会导致的问题就是,当一个线程去实例化一个变量,此时发生了重排序,还没有初始值就有引用了(也就是不等于null),那么此时另一个线程去获取这个变量的时候,使用null去判断这个变量是否创建好的时候,就会出现问题(未初始化完成就可以进行获取)
volatile关键字可以防止指令发生重排序,也就一定要赋上初始值,才可以引用赋值,这样就解决了上面的问题了
volatie的实现
volatie的实现,其实本质上是一条汇编的lock指令
这个lock指令有两步
-
将当前处理器缓存行的数据写回到系统内存中
-
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
我们先来谈谈这个系统缓存和处理器的缓存行
处理器,也就是CPU,也可以理解成当前线程,他是不会直接和内存(主线程的变量都放在内存中)进行通信的,而是会先将系统内存的数据读取到内部缓存后再进行操作,操作的也是内部缓存
如果对volatie修饰的变量进行修改,那么第一步就是将这个变量所在缓存行的数据写回到系统内存,也就是修改,第二步就是告知其他CPU里缓存了该内存地址的数据无效,需要重新去获取
第二步的底层实现其实就是缓存一致性协议,每个处理器都会通过嗅探总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,需要重新从系统内存中把数据读取到处理器缓存中去
内存屏障
同时,volatile还要去实现防止重排序,而防止重排序的底层实现是内存屏障
在指令之间加上内存屏障,那么上下两条指令是不可以发生重排序的
现在我们来看看其字节码上的实现
也不知道为什么,window的javap -v命令无法显示变量的字节码情况
其实volatile的的字节码底层实现是加了一个acc_volatile修饰的,详细的执行过程就是上面所述
缓存行自动填充
上面提到过,volatile的实现是针对缓存行来进行操作的,而缓存行一般是64字节的,而往往一个变量可能不够8字节,那么就会导致一些不希望通知的数据也通知给了其他线程,这个数据也会被设置成无效状态(无效状态是整个缓存行无效),那么这个无关变量的使用效率就会降低,所以,我们需要缓存行填充,来让一个缓存行里面只有volatile变量和一些无关变量
synchronized关键字
synchronized在jdk1.6之前是一个重量级锁(通过操作系统的互斥量来实现的)
之后进行了一系列的优化,在有些情况下没有变得这么重了
前面复习过,synchronized是一个内部锁,又分为对象锁和类锁,也复习过synchronized可以加在方法上,也可以只锁住方法里面的一段代码块
-
对于类中的非静态方法,如果在方法上加锁,加的就是对象锁
-
对于类中的静态方法,如果在方法上加锁,加的就是类锁
-
对于方法块,加的是指定的锁,可以指定是类锁,也可以指定是对象锁
synchronized底层实现
我们看看synchronized是如何实现的
首先,我们这里要首先认识的是,synchronized的信息是放在Java对象头里面的
java代码层级上
java代码就是简单的加上synchronic关键字
字节码层级上
我这里创建了一个类,类中有两个加锁的方法,一个是方法上加锁,一个是代码块加锁
接下来看看字节码会怎样
通过比较不加锁的方法,即sayTwo
先看第一个加锁的方法,即say方法
通过跟不加锁的方法比较,方法加锁与方法不加锁唯一的不同就是在方法修饰上加了synchronized,但这并不是全部的,所以使用javap -v来看一下真实的
可以看到,底层是多了一个ACC_SYNCHRONIZED的修饰
现在看一下代码块

太长了,分两张来截
可以看到里面多了两个东西,MonitorEnter与MonitorExit
MonitorEnter指令是在编译后插入到代码块的开始位置的,而MonitorExit是插入到方法结束处和异常处的
任何对象都有自己的一个monitor与之关联,当这个monitor被持有后,这个对象就会处于锁定状态
当有线程执行到MonitorEnter指令后,会去尝试获取这个对象拥有的monitor的所有权,这一步相当于是获取对象的锁
JVM还规定每一个MonitorEnter都必须要有一个MonitorExit与之对应匹配
所以,代码块在字节码层级上实现的方式就是加MoniorEnter与MonitorExit,而方法加锁在字节码层级上的实现也是加了acc_synchronized来标识这个是一个同步方法
Java对象内存布局
锁的信息是存在哪里的,所以要去看一下Java对象的内存布局
Java对象的内存布局有三种
-
对象头
-
markword:记录锁的信息
-
类型指针:标记属于哪一个class,对象是哪种类型
-
数组长度:如果该对象是一个数组,这里会记录数组长度
-
实例数据:即一些成员变量
-
对齐:java对象大小必须要可以被8整除,所以后面要进行对齐
对象头
synchronized用的锁其实就是存在Java对象头里面的,而且很明确存在于markword里面,而markword这部分占了8个字节,也就是64位,不过可能会进行压缩,从而变成4个字节,所以即可能是,64位也可能是32位,而整个Java对象头是12个字节,前面两个部分都可能会进行压缩,从8个字节变成4个字节
下面来看看markword的结构
| 锁状态 | 25bit(对象的hashcode) | 4bit(分代年龄) | 1bit(是否是偏向锁) | 2bit(标志位) |
| — | — | — | — | — |
| | | | | |
锁状态
锁的状态一共有4种
-
无锁
-
偏向锁
-
轻量级锁
-
重量级锁
无锁
无锁就是没有加锁