文章目录
并发编程安全三大问题
原子性(Synchronized, AtomicXXX、Lock可以解决)
涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果。
老掉牙的代码
public class Demo {
int i = 0;
public void incr(){
i++;
}
public static void main(String[] args) {
Demo demo = new Demo();
Thread[] threads=new Thread[2];
for (int j = 0;j<2;j++) {
threads[j]=new Thread(() -> { // 创建两个线程
for (int k=0;k<10000;k++) { // 每个线程跑10000次
demo.incr();
}
});
threads[j].start();
}
try {
threads[0].join();
threads[1].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(demo.i);
}
}
输出不是2000就是因为i++不是原子操作
在target的字节码用终端打开通过javap -v Demo.class 查看字节码指令如下。
注意看我的注释
public void incr();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // 访问变量i
5: iconst_1 // 将整形常量i放入操作数栈
6: iadd // 把操作数栈中的常量i出栈并相加,将相加的结果放入操作数栈
7: putfield #2 // 写回内存,即访问类字段(类变量),复制给Demo.count这个变量
10: return
LineNumberTable:
图解
注意:这张图的++是原子操作,不是代码里面的i++这种了。
一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前。
执行的顺序如图所示。就会导致最终的结果是1,而不是2。
提前预告volatile也解决不了这个问题,还是因为原子性
- 如果是上图这个顺序,那么volatile可以解决,因为写回内存后,另一个线程切换回来在count++之前会被通知count已经更新了,需要重新取。
- 但是如果是这个顺序,当右边的count++执行完后但还未写回内存时,又切换会左边的线程了,那么左边的线程仍然不知道count已经更新了,它继续执行++写回内存,右边的线程这时会收到count更新了,需要重新取,然后就去内存重新取count到寄存器中,但是因为已经执行了++操作了,所以不会再执行++了,右边的线程也直接写入内存了,所以结果还是1
可见性(Synchronized,volatile可以解决)
有序性 (Synchronized,volatile可以解决)
Synchronized的基本应用
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
Synchronized的原理
由这种代码我们不难想象到是通过修改对象里面的信息来获得锁的
synchronized (object){
}
对象头
- mark-word:对象标记字段占8个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标
记位,偏向锁标记位、分代年龄等。 - 类元信息:Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( - XX:-UseCompressedOops )后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的
元数据对象)的内存地址。即:类的成员变量的指针(因为有些成员变量又是类嘛) - 实例数据:包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比
特位、int占4个字节32比特位。 - 对齐:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理
系统要求对象起始地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例
数据部分没有对齐的话,就需要通过对齐填充来补全。
通过ClassLayout打印对象头
引入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class ClassLayoutDemo {
Object o=new Object();
public static void main(String[] args) {
ClassLayoutDemo classLayoutDemo=new ClassLayoutDemo();
System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
}
}
解析
前面三行是对象头
一二行是mark-word(对象标记)
三行是类元指针
最后一行是实例数据
这个例子没有对齐填充,因为8+4+4已经是8的整数倍了(也可以看到最后一行标注了丢失的空间为:0+0个字节,说明没有用额外的空间来填充)
如果不要object
可以清晰地看到最后标注了丢失的空间为:0+4,说明多用了4个字节的空间来填充。
对齐填充
64位处理器(即CPU)一次读取的数据最小工作行(缓存行)是8个字节
例:32位
4个字节起始地址刚好就在CPU读取的地址处,这种情况下,CPU可以一次就把这个指令读出
而当4个字节按照如下图所示分布时
假设CPU还在同一个地址取数据,则本次操作会进行两次内存读取才能读完想要的,相较第一种直接取出多了一次操作。CPU本来就会做大量的数据运算和操作,如果遇到这种情况很多的话,CPU将做出很多的“多余操作”,严重影响性能。所以就需要对齐填充,让CPU尽量只取一次!
此外如果是volatile的情况,CPU读取到不必要的数据,还会容易缓存失效,频繁去内存重新取不需要的数据。
- 例:没有对齐填充时,A线程只想要读取a,但a只占4字节,因此会连带b也会一并读取到缓存行中;
- 同一时刻B线程只想要读取b,但b只占4字节,因此会连带a也会一并读取到缓存行中;那么这种情况下就会存在伪共享问题,这会导致A和B线程在进行数据变更并读入到内存时CPU会频繁地直接去内存获取最新值,导致性能下降。
锁的状态
重量级锁的代价
- 用户态和内核态的切换
- 没有获得锁的线程又会继续被阻塞,又是用户态和内核态的切换
锁的优化
- Jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
- 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
- 设计的目的是为了减少重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题,其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。
解释流程 - 一个A线程第一次直接顺利进入同步块,那么这里线程A就把对象的mark-word信息改为与它有关的了,就形成了偏向锁了。后面A线程再进来,就只需要对比一下了,而不需要再次修改mark-word信息了。
- 如果这时B线程过来了,且A没执行完,那么就会升级为轻量级锁了(即B自旋一会,等A执行完)。如果A执行完了,B线程过来,那么锁就重新偏向B(即B去修改mark-word信息标识为是它的了)。
- 如果B自旋的时间或次数超过我们规定的,那么就会升级到重量级锁了。
轻量级锁即线程在自己的线程栈帧中会创建一个LockRecord,用CAS操作把markword设置为指向自己这个线程的LR的指针,设置成功后表示抢占到锁。伪代码如下
for(;;){
if(condition)break;//控制自旋条件,不满足就不能再自旋了,升级为重量级
CAS操作
}
根据流程不难发现思想
- 轻量级锁就是为了避免线程阻塞(即内核态和用户态之间切换的开销)而优化的,轻量级锁就是自旋操作,但如果自旋也有限制,太久也不行,因为自旋太久的话,相当于代价已经超过内核态和用户态之间切换的开销了。就需要使用代价更小的重量级锁了
一般,线程超过10次自旋(-XX:PreBlockSpin参数配置),或者自旋线程数超过CPU核心数的一半就会升级为重量级锁,在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争的情况来自动控制自旋的时间。
重量级锁是通过ObjectMonitor来实现的