数据共享性是线程安全的主要原因之一。 如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。 在多线程编程中,数据共享是不可避免的。 最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中的数据。
资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。 我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。 所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。 如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。 对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。 但是对共享数据的写操作,一般就需要保证互斥性。
线程只能操作自己工作空间中的数据。 每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。
如何保证可见性
volatile
关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。synchronized 保证 unlock 之前必须先把变量刷新回主内存。
示例如下
/**
* @author BNTang
*/
public class VisibilityTest {
private static boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
// 如果添加了打印语句, 变量不加 volatile 程序也会结束
// 因为在println当中触发了程序的可见性,使用 synchronized 修饰
System.out.println("hello");
}
System.out.println("end");
}
public static void main(String[] args) {
new Thread(VisibilityTest::method).start();
SleepTools.sleepSecond(1);
running = false;
}
}
/**
* @author BNTang
*/
public class VisibilityTest {
/**
* 成员变量是线程共享的
*/
private static volatile boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
}
System.out.println("end");
}
public static void main(String[] args) {
// 创建一个线程 新线程
new Thread(VisibilityTest::method).start();
// 主线程 main
SleepTools.sleepSecond(1);
running = false;
}
}
/**
* @author BNTang
*/
public class VisibilityTest {
private static boolean running = true;
private static void method() {
System.out.println("start");
while (running) {
}
System.out.println("end");
}
public static void main(String[] args) {
// 程序一直结束不了
new Thread(VisibilityTest::method).start();
SleepTools.sleepSecond(1);
running = false;
}
}
1.X = 5
2.Y = X
3.i++
4.a = a + 1
为了提高性能,编译器和处理器可能会对指令做重排序。
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
程序中的顺序不一定就是执行的顺序,编译时重排序,指令重排序。
通过 volatile 和 synchronized 可以保证程序的有序性。
不同的程序指令之间的顺序是不允许进行交换的,即可称这些程序指令之间存在数据依赖性。
哪些指令不允许重排
写后读: a = 1;b = a;写一个变量之后,再读这个位置。
写后写: a = 1;a = 2;写一个变量之后,再写这个变量。
读后写: a = b;b = 1;读一个变量之后,再写这个变量。
发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
TODO
重新排序后的运行结果和之前的结果要保持一致。
Happen-Before
程序次序规则(Program Order Rule)在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。 锁定规则(Monitor Lock Rule)一个 Unlock 的操作肯定先于下一次 Lock 的操作, 后一次加锁必须等前一次解锁, 这里必须是同一个锁。同理我们可以认为在 synchronized 同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。 volatile 变量规则(volatile Variable Rule)对同一个 volatile 的变量,先行发生的写操作,肯定早于后续发生的读操作。 线程启动规则(Thread Start Rule)Thread 对象的 start() 方法先行发生于此线程的每一个动作。 线程中止规则(Thread Termination Rule)Thread 对象的中止检测(如:Thread.join())操作,必行晚于线程中所有操作。 线程中断规则(Thread Interruption Rule)对线程的 interruption() 调用,先于被调用的线程检测中断事件 (Thread.interrupted()) 的发生。 对象中止规则(Finalizer Rule)一个对象的初始化方法先于一个方法执行 Finalizer() 方法。 传递性(Transitivity)如果操作 A 先于操作 B、操作 B 先于操作 C, 则操作 A 先于操作 C。
当一个变量加上 volatile, 就是告诉虚拟机, 每一次要使用变量时, 总是要从主内存当中读取。 如果要修改修饰的变量, 一定要把修改完后的值, 刷回主内存,即不会出现数据脏读的现象。 被 volatile 修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
不是线程安全的, 只能保存变量的可见性, 不能保证变量的原子性, 对共享变量的修改,其他的线程马上能感知到,不能保证原子性(读写, i++)。
/**
* @author BNTang
*/
public class Test {
public static volatile int counter = 1;
public static void main(String[] args) {
counter = 2;
System.out.println(counter);
}
}
查看字节码, 使用 JavaP 查看字节码, 找到字节码执行反编译操作
javap -v Test
使用 idea 外部工具
-v $FileClass$
修饰 counter 字段的 public、static、volatile 关键字 在字节码层面分别是以下访问标志:
ACC_PUBLIC
,ACC_STATIC
,ACC_VOLATILE
volatile 在字节码层面,就是使用访问标志:ACC_VOLATILE
来表示 供后续操作此变量时判断访问标志是否为ACC_VOLATILE
,来决定是否遵循 volatile 的语义处理
如上是在 JVM 层面所看到的
HSDIS (HotSpot disassembler) 一个 Sun 官方推荐的 HotSpot 虚拟机 JIT 编译代码的反汇编插件,其实际上就是一个动态库。这里我们直接从网上下载与我们系统对应的编译后文件,然后直接将其放置到 JDK 的 bin 目录下即可
下载地址:http://lafo.ssw.uni-linz.ac.at/hsdis/att/ 蓝奏云:https://wwe.lanzoui.com/impV0q30t1e
把下载好的 hsdis
解压放到 bin
目录当中 (jdk11 是放在 bin 目录当中,jdk8 是放在 jre\bin\server 目录当中)
在程序运行时添加参数
-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*Test.main
以上内容只需要修改的地方就是修改类名,前面的 *
号不要去掉,然后再加上对应的方法名称即可,配置好了在次运行程序发现控制台输出结果如下
volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令
lock 前缀指令实际上相当于是一个内存屏障(也称内存栅栏)内存屏障会提供 3 个功能
它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成 它会强制将对缓存的修改操作后的数据立即写入主存 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。对缓存行进行加锁处理
当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序
当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序
当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序
volatile 是通过编译器在生成字节码时,在指令序列中添加 “内存屏障” 来禁止指令重排序的
sfence:写屏障 (Store Barrier) 在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,保证写入的数据立刻对其他线程可见 lfence:读屏障 (Load Barrier) 读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,保证读取的是最新的数据 mfence:即全能屏障 (modify / mix Barrier) 兼具 sfence 和 lfence 的功能 lock 前缀:lock 不是内存屏障,而是一种锁
LoadLoad 屏障:像 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕 StoreStore 屏障:像 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见 LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕 StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见
JVM 的实现会在 volatile 读写前后均加上内存屏障,在一定程度上保证有序性
在每个 volatile 写操作的前面插入一个 StoreStore 屏障 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
例如下方的几个栗子:
StoreStoreBarrier volatile 写操作 StoreLoadBarrier
LoadLoadBarrier volatile 读操作 LoadStoreBarrier
在 Java 提供的 Api 中有一个名称为 Unsafe
的类
/**
* @author BNTang
*/
public class Test {
public static void main(String[] args) {
Unsafe unsafe = Unsafe.getUnsafe();
// 添加读屏障
unsafe.loadFence();
// 添加写屏障
unsafe.storeFence();
// 读写屏障
unsafe.fullFence();
}
}
总线加锁 缓存一致性协议
MESI
MESI 中每个缓存行都有四个状态
E(exclusive)独占状态 S(shared)共享状态 M(modified)修改状态 I(invalid)失效状态
cpu 在启动的时候,会采用监听模式,一直会监听消息的传递 如果在读取一个变量时,发现被 lock 修饰时,其它 CPU 会监听到现在有人在读取数据 假设现在 cpu1 读取到一个变量 a = 1, 是第一次读,会把这个变量 a 标记成
E(独占状态)
如果此时,有另一个 cpu 也读取变量 a, 此时 cpu1 也会可监听到,并且会把状态更改为S状态(共享状态)
在 cpu2 当中会也会标记成S状态(共享状态)
如果两个 cpu 都要对 a 变量进行修改
假设 cpu1 把 a 改为 2 cpu2 要把 a 改成 3 会分别在 cpu 当中的缓存行中加锁,一旦加锁成功后,就可以来修改里面的内容,并且把状态标志成
M(已修改状态)
假设 cpu2 缓存行加锁成功,会向消息总线发送一个本地写缓存的消息 如果两个人同时加锁,发消息给消息总线 此时总线就要采取内部仲裁的方式来决定谁先成功 通过总线的高低电位来裁决 消息发成功后,会被 cpu1 捕捉到,cpu1 会把自己当中的变量置为I(无效状态)
到内存当中再读取最新的数据 在发出消息后,并不是立马就写入到内存当中,会先把写的数据放到一个store buffer
当中,等 cpu1 把消息变为无效后,才会写到入到内当中 当 cpu1 把消息设置会无效后,会把原来的数据 a = 1 放到一个 queue 队列当中,并且会发送一个消息通过已经置为无效
一个线程写, 多个线程读
状态标志(开关模式) 双重检查锁定(double-checked-locking)DCL
/**
* 单例
*
* @author BNTang
*/
public class Singleton {
/**
* 实例
*/
private volatile static Singleton instance = null;
/**
* 单例
*/
private Singleton() {}
/**
* 获得实例
*
* @return {@link Singleton}
*/
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
需要利用顺序性
volatile 只能修饰变量,synchronized 只能修饰方法和语句块 synchronized 可以保证
原子性
,volatile 不能保证原子性 都可以保证可见性
,但实现原理不同, volatile 对变量加了lock
synchronized 使用monitorEnter
和monitorexit monitor
volatile 能保证有序
,synchronized 可以保证有序性
,但是代价(重量级)并发退化到串行 synchronized 引起阻塞 volatile 不会引起阻塞
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。