java 并发相关(2) - synchronized和volatile关键字
多线程间的同步与锁
1、线程问题
多个线程并发执行可以提高我们程序的执行速度和效率,但也会带来缓存不一致、执行顺序无序等问题,java提供了一些锁和同步的机制,一些原子类,线程安全集合等手段来保证线程之间的安全执行。
2、保证线程安全的三个方面:原子性、可见性、有序性
- 原子性:原子性一般指一组操作是一个整体,要么全部成功,要么全部失败。
多线程下的原子性表现为线程对共享变量的操作是不可分割的,即操作共享变量的其他线程,只能看到执行前或执行后的结果,不能看到操作的中间状态。
-
可见性:一个线程修改了共享变量的值,其他线程能立即得知这个修改。
-
有序性:指的是程序执行的指令按代码顺序执行,因为在java中编译器和处理器会对指令进行重排序,可能会对多线程任务结果造成影响。
3、synchronized
- synchronized定义
java提供同步机制的关键字,当它用来修饰一个方法或代码块的时候,能保证该代码块的同步执行,及多个线程同一时间只能有一个执行同步代码块。
synchronized锁有两类范围,一个是实例锁,一个是类锁,也就是synchronized(this or Object.class)。
- synchronized的锁的简单表现
1、当一个线程持有synchronized锁后,其他线程不能访问该对象的synchronized方法,但可以访问非synchronized方法,体现锁的互斥性。
2、当一个线程持有synchronized锁之后,可以任意调用该对象的其他synchronized方法,体现锁的可重入性。
3、类锁和实例锁,synchronized的两个作用域,两者之间是不会相互影响的。
4、使用synchronized锁时,要注意锁的粒度,即要注意不要用synchronized同步没必要保证同步的代码块。
- synchronized的原理
synchronized的语义底层是通过一个Object的monitor的监视器对象来完成对代码块的锁定,每个对象都有自己的一个monitor监视器对象,如果线程没有获得对象监视器,就会处于阻塞状态(Blocking)。
示例方法:
public synchronized String getName(){
return name;
}
public void setName(String name) throws InterruptedException {
synchronized (this){
this.name = name;
this.wait();
}
}
注:javac Some.java -> javap -verbose Some
反编译后:
经过反编译后可以发现synchronized修饰后的代码块前后加上了monitorenter和monitorexit这两条指令。
线程执行synchronized方法块的流程:对应monitorenter和monitorexit指令
monitorenter:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
monitorexit:
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
从这里我们可以看到synchronized锁是排他的,是可重入的。
4、volatile
- JMM模型 线程和主内存之间的抽象关系,实际上跟CPU的高速缓存有关。
共享变量存储在主内存中,每个线程有一个私有的内存空间(CPU内的高速缓存)保存着共享变量的副本,线程对共享变量操作是直接对本地内存进行操作,这会造成共享变量的不一致性。
- volatile 保证共享变量的可见性
volatile修饰的共享变量,当一个线程对这个变量进行修改操作后,jvm会把线程本地内存中变量的新值刷新到主内存中,持有这个变量的其他线程,在私有内存中变量会失效,从主内存重新获取。
基础例子:通过volatile修饰的变量isStop变量才能保证两个线程对isStop的可见性
static boolean isStop = false;//加不加volatile
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
isStop = true;
System.out.println("thread1 is end");
});
Thread thread2 = new Thread(() -> {
while(!isStop){
}
System.out.println("thread2 is end");
});
thread2.start();
Thread.sleep(1000);
thread1.start();
}
- volatile 保证操作的有序性
重排序:
编译器和处理器对于操作指令没有数据依赖的情况下,可能会发生指令序列的重排序。
volatile保证有序性:禁止编译器的优化和重排、通过内存屏障限制处理器重排。
内存屏障的作用:
1、确保指令重排序时不会把屏障后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面。
2、强制把写缓冲区/高速缓存中的数据等写回主内存,让缓存中相应的数据失效;
- volatile 内存屏障
Load Barrier 读屏障
在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据;
Store Barrier 写屏障
利用缓存一致性机制强制将对缓存的修改操作立即写入主存,让其他线程可见,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据。
5、synchronized+volatile实现懒汉式单例的双重检查
加synchronized的作用:保证多线程状态下,只有一个线程执行new SingletonThread2 操作
第二个if(instance == null)作用: 如果A、B两个线程都通过第一次(instance == null)检查,进入同步块执行,A执行了同步块,创建了SingletonThread2 对象,B线程就不能在执行new操作了。
加volatile作用:禁止重排序,instance = new SingletonThread2()操作并不是原子的,可以拆分为3步,java编译器和处理器可能会进行指令重排序,会造成instance已经有内存地址,却没有初始化,这种情况下会获取一个空的instance对象。
public class SingletonThread2 {
// 禁止指令重排序
private volatile static SingletonThread2 instance;
public static SingletonThread2 getInstance() {
if (instance == null) {
// 加同步锁
synchronized (SingletonThread2.class){
if(instance == null){
//防止多个线程在外边等待进入
instance = new SingletonThread2();
// 指令重排序可能会变成 1 -> 3 ->2
// 1、memory = allocate(); //分配SingletonThread2对象的内存空间地址
// 2、ctorInstance(memory); //初始化对象,赋初值
// 3、instance = memory; //设置instance指向刚分配的内存地址
}
}
}
return instance;
}
}
6、补充知识: MESI 缓存一致性协议
MESI是一种比较常用的缓存一致性协议,MESI表示缓存行的四种状态,分别是:
1、M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
2、E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3、S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
4、I(Invalid) 表示缓存已经失效
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它CPU的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU读请求:缓存处于 M、E、S 状态都可以被读取,I 状态CPU 只能从主存中读取数据
CPU写请求:缓存处于 M、E 状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才行。
参考:https://blog.csdn.net/mashaokang1314/article/details/88803900 (从硬件内存架构理解Volatile(内存屏障))