线程安全之原子性问题
x++ ,在字节码文件中对应多个指令,多个线程在运行多个指令时,就存在原子性、可见性问题
赋值
多线程场景下,一个指令如果包含多个字节码指令,那么就不再是原子操作。因为赋值的同时,读到的x的值可能已经发生变化,被其他线程修改了。
x = 10; //原子操作,只有一个操作,10赋值给x,之后写入内存
y = x; //非原子操作,1、先从内存读x的值 2、x的值赋值给y,再写入内存
x++; //非原子操作,同上
count++
模拟多个线程count++,最终count不一定等于1000。
public class Demo{
private static int count=0;
public static void inc(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
for(int i=0;i<1000;i++){
new Thread(()->Demo.inc()).start();
}
Thread.sleep(3000);
System.out.println("运行结果"+count);
}
}
线程安全之可见性问题
多个线程访问同一变量,一个线程修改了该变量的值,其他线程能立刻看到修改的最新值。
CPU缓存不一致问题
计算机核心组件:CPU、内存、I/O设备
计算速度对比:CPU > 内存 > I/O设备
为了提升计算性能,CPU从单核升级到了多核,以及超线程技术。但后两者的处理性能并没有跟上。为了平衡三者的速度差异,做了很多优化:
1.CPU增加了高速缓存,很好的解决了CPU和内存的速度矛盾。
2.操作系统增加了进程、线程。通过CPU时间片切换最大化提高CPU录用率。
3.编译器指令优化。
CPU高速缓存
线程是CPU调度的最小单元。
主内存 、总线 、CPU多级缓存
CPU先在L1找数据,L1没有去L2,L2没有去L3,L3没有去内存找。
CPU计算时,直接从缓存中读取数据,计算完成后再写入缓存中,最后再把缓存中的数据同步到内存。
缓存不一致问题
每个CPU拥有自己的缓存,如果同一数据在不同缓存中,缓存值不一样,就存在缓存不一致的问题。
解决方案: 总线锁、缓存锁
总线锁
当一个CPU要对共享变量操作时,在总线上发出LOCK#信号,锁住CPU和内存的通信,锁住期间,其他CPU不能操作缓存了该数据内存地址的缓存。
总线锁开销比较大,所以这种机制显然不合适。
缓存锁
基于缓存一致性协议
缓存一致性协议(MESI)
M(Modify)
被修改的。该数据只在当前CPU的缓存中有,且与主内存不一致。
E(Exclusive)
独占的。该数据只在当前CPU缓存中,且没有被修改过。
S(Shared)
共享的。该数据被多个CPU缓存,且各缓存中的数据与主内存一直。
I(Invalid)
失效的。当前CPU中缓存的该数据失效。
i=1,该CPU独占且与内存数据一致,此时处于E状态,如果i变成了2,则状态变为M。
CPU只能从缓存中读取M、E、S状态的数据,I状态的数据要到内存中读取。
CPU可以直接写M、E状态的数据。S状态的数据,需要先将其他CPU中缓存行设置为无效才能写。
Store Bufferes
CPU0对缓存中的共享变量写入时,先发送一个失效的消息给到缓存了该共享变量的CPU,并且要等到它们的确认回执。这个过程中,CPU0处于阻塞状态。为了避免浪费资源,所以引入Store Bufferes
1.CPU0将数据写入Store Bufferes中,同时发送invalidate消息给CPU1,之后就可以继续处理其他指令。
2.CPU1收到invalidate消息后,将要修改的变量i放入invalidate queue(失效队列中),并且给一个ACK应答。
3.CPU0收到CPU1的invalidate acknowledge之后,将Store Bufferes中的数据存储至缓存行(cache line),最后再从缓存行同步到内存。
内存屏障(memory barrier)
内存屏障就是把Store Bufferes中的指令写入内存,内存屏障之前的内存访问操作先于其后的操作完成。保证共享变量对其他线程的可见性。
写屏障(store memory barrier)
store之前的所有已经存储在Sotre Bufferes中的数据同步到内存。即将Sotre Bufferes中的a==1同步到内存后,才能执行后面的b=1。
读屏障(load memory barrier)
load之后的读操作,都在load屏障之后执行。配合store屏障,使得store之前的写操作对load之后的读操作是可见的。
全屏障(full memory barrier)
full前的读写操作同步到内存后,才能执行full之后的操作。
重排序问题
为了提升性能,编译器和CPU会对指令做重排序,源码到最终执行,会经过三种重排序
注:2、3属于CPU重排序
JMM(Java Memory Model)
JMM定义了共享内存中,多线程的读写操作规范。实现了将共享变量存储到内存、从内存中取出共享变量的底层细节。从而解决CPU多级缓存、处理器优化、指令重排序导致的内存访问问题。保证了并发场景下的可见性。
缓存一致性问题,有总线索、缓存锁,缓存锁基于MESI协议。
指令重排序问题,硬件层面提供了内存屏障。
JMM在此基础上提供了volatile、final等关键字,来解决可见性、重排序问题。
内存屏障分4类
HappenBefore
如果前一个操作的结果需要另一个操作时可见,那么这两个操作之间必须存在happens-before关系。这两个操作可以是同一个线程,也可以是不同线程。
1、程序顺序规则(as-if-serial语义)==
单个线程中的代码顺序不管怎么变,对于结果来说是不变的。
依赖问题,如果两个指令存在依赖关系,不许重排序。
1 happenns-before 2,3 happens-before 4
2、volatile变量规则
volatile修饰的变量,写操作一定happens-before读操作。
2 happens-before 3
3.传递性规则
如果1 happenns-before 2,3 happens-before 4,那么1 happenns-before 4。
4.Start规则
线程A 中ThreadB.start()操作happenns-before线程B中的任意操作。
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 子线程中,x==10
});
x = 10; // 此处对共享变量 x 修改,此操作对于子线程可见。
t1.start(); // 主线程启动子线程
}
5.join规则
线程A中ThreadB.join(),那么线程B的所有操作happenns-before线程的ThreadB.join()操作。
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 子线程中,x==10
x=100; //修改X
});
x = 10; // 此处对共享变量 x 修改,此操作对于子线程可见。
t1.start(); // 主线程启动子线程
t1.join(); //子线程的修改,在主线程执行t1.join()之后皆可见。X==100
}
6.监视器锁的规则
解锁happenns-before下一个加锁。
synchronized (this) { // 此处自动加锁
if (this.x < 12) { // x 是共享变量, 初始值 =10
this.x = 12;
}
} // 此处自动解锁
线程A中x = 12,那么线程B拿到锁之后,能看到x == 12。
Synchronized
synchronized可以解决线程原子性问题,synchronized块之间的操作具备原子性。
Java SE 1.6优化了synchronized,引入了偏向锁、轻量级锁,减少获得锁、释放锁带来的性能开销。
public class Demo{
private static int count=0;
public static void inc(){
synchronized (Demo.class) { //基于Demo对象的生命周期来控制锁粒度
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}
public static void main(String[] args) throws InterruptedException {
for(int i=0;i<1000;i++){
new Thread(()->Demo.inc()).start();
}
Thread.sleep(3000);
System.out.println("运行结果"+count);
}
}
对象锁
synchronized 修饰方法
synchronized 修饰代码块 this/Synchronized_demo.this
多线程跑同一个对象
全局锁/类锁
synchronized 修饰 static 方法
各线程之间 抢锁
synchronized 修饰代码块 Synchronized_demo.class
各线程之间 抢锁
对象
对象的存储布局
对象头
包含了Mark Word、class指针、数组的长度(对象为数据时才有)
Mark Word(自身运行时数据)记录了对象和锁有关的信息。
32位操作系统为例:
synchronized 锁升级
所以在JDK1.6之后,synchronized中,锁存在4种状态:无锁、偏向锁、轻量级锁、重量级锁,锁状态由低到高不断升级。
偏向锁
大部分情况下,锁总被同一个线程多次获得,所以引入偏向锁。
对象头中存储线程ID,从而避免同一个线程再次进入、退出时获取锁、释放锁的操作。
如果多个线程竞争该锁,那么偏向锁就是一种累赘,可通过JVM参数UseBiasedLocking 来设置开启或关闭偏向锁。
偏向锁获取逻辑
1.获取锁对象的Mark Word,判断是否处于可偏向状态。
(biased_lock=1且 ThreadID 为空,则表示可偏向)
2.如果是可偏向状态,则通过CAS操作,把当前线程ID写入锁对象的Mark Word。
1)CAS成功,则获得偏向锁
2)CAS失败,说明偏向锁被其他线程占有,当前锁存在竞争,则撤销偏向锁,升级成轻量级锁。
3.如果是已偏向状态,则检查锁对象的Mark Word中的ThreadID 与当前线程的 ThreadID 是否相等。
1)如果相等,则无需再获得锁。
2)如果不相等,说明当前锁偏向于其他线程,要么重新偏向,要么撤销偏向锁,升级成轻量级锁。
偏向锁撤销逻辑
1.如果原获得偏向锁的线程同步代码块执行完了,那么锁对象设置成无锁状态,再重新偏向。
如果没有执行完,则在一个安全点停止拥有锁的线程A,修复锁记录和Mark Word,使其变成无锁状态,再唤醒线程A,将当前锁升级成轻量级锁。
轻量级锁
升级为轻量级锁之后,对象的Mark Word也会相应的变化。
轻量级锁的加锁逻辑
1.线程在自己的栈帧中创建锁记录LockRecord。
2.将锁对象 对象头中的MarkWord复制到线程刚刚创建的LockRecord。
3.将锁记录中的owner指针指向锁对象。
将锁对象对象头的MarkWord替换为指向锁记录的指针。
轻量级锁的解锁
锁释放逻辑其实就是获得锁的逆向逻辑。
通过CAS操作把线程栈帧中的LockRecord替换回到锁对象的MarkWord中,如果成功,表示没有竞争。如果失败,表示当前锁存在竞争,膨胀称为重量级锁。
自旋锁
轻量级锁在加锁的过程中,使用了自旋锁。
当一个线程来竞争锁时,会原地循环等待,直到锁被释放后,该线程直接获得锁,所以轻量级锁适用于同步代码块执行很快的场景。
自旋必须要一定的条件限制,否则不断循环,反而消耗CPU资源。默认情况下,自旋次数10次,可以通过preBlockSpin修改。
JDK1.6之后,引入自适应自旋锁,可根据前一次自旋时间以及锁拥有者的状态来觉得自旋次数。
重量级锁
当轻量级锁膨胀到重量级锁,未抢到锁的线程只能被挂起阻塞,等待被唤醒。
重量级锁是依赖对象内部的monitor锁来实现的,而monitor锁又依赖操作系统的MutexLock(互斥锁),所以重量级锁又称互斥锁。
当线程要去执行一段被synchronize修饰的方法或代码块时,需要先获得被synchronize修饰的对象的monitor监视器(monitorenter),获取失败,线程进入同步队列,变成blocked状态,直到锁被释放之后,当前线程会被唤醒,重新尝试对monitorenter的获取。
synchronized的执行过程
- 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁 。
- 如果不是,则使用CAS将当前线程的ID替换Mard Word里的线程ID,如果成功则表示当前线程获得偏向锁,置偏向标志位1 ,如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁 ,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级,如果自旋失败,则升级为重量级锁。
sleep
Thread.sleep(1000)
阻塞1秒,期间不释放锁
wait
wait()阻塞当前线程,释放锁,并把当前线程放入等待队列,等待被唤醒。
wait()前提是必须先获得锁,这样才能释放锁,一般配合synchronized 关键字使用,即一般在synchronized 同步代码块里使用 wait()、notify/notifyAll() 方法。
public class ThreadA extends Thread{
private Object lock;
public ThreadA(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock){
System.out.println("start ThreadA");
try {
lock.wait(); //实现线程的阻塞,并且释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end ThreadA");
}
}
}
notify
notify()是将锁交给含有wait()方法的线程,让其继续执行下去,所以必须先持有锁
public class ThreadB extends Thread{
private Object lock;
public ThreadB(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock){
System.out.println("start ThreadB");
lock.notify(); //唤醒被阻塞的线程
System.out.println("end ThreadB");
}
}
}
notifyAll
notifyAll()唤醒等待队列里的线程,等待队列并没有资格竞争锁,而是线程被移到同步队列后,再竞争锁。
jion
主线程合并子线程。join底层是使用wait()来实现,所以会释放锁。
Volatile
Vloatile遵循HappenBefore规则,能保证新值在修改后立即同步回主内存,每次使 用前从主内存刷新。
普通变量无法保证这一点,因为普通的共享变量修改后,什么时候同步写回主内存是不确定的,其他线程读取时,内存中可能还是原来的旧值。
public class App {
public volatile static boolean stop=false;
public static void main( String[] args ) throws InterruptedException {
Thread t1=new Thread(()->{
int i=0;
while(!stop){ //condition 不满足
i++;
}
System.out.println(i);
});
t1.start();
Thread.sleep(10);
stop=true; //true 主线程设置stop为true,对子线程可见。
}
}
final关键字提供了内存屏障的规则