并发编程之线程安全

一、线程的安全问题

线程的安全性问题的本质其实就是并发造成的,举个例子,就好比一对夫妻两个人同时对一个银行账户进行操作,这个银行账户如果只有100块钱,当丈夫去取了100的时候,这么一瞬间,银行还没有扣款,妻子也取了100块,这个时候岂不是银行亏了啊。这就是安全性问题。

安全性问题其实就三种:

1、原子性问题

代码举例:

public class AtomicDemo {
    public static int count=0; //100
    public static void add(){
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }
 
    public static void main(String[] args) {
        for(int i=0;i<1000;i++){
            new Thread(()-> AtomicDemo.add()).start();
        }
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("运行结果:"+count);
    }
}

通过javap -v AtomicDemo.class 查看二进制字节码

12: getstatic     #5                  // Field count:I
15: iconst_1
16: iadd
17: putstatic     #5 
 
#invokestatic:调用静态方法
#invokespecial:调用实例构造器方法、私有方法和父类方法
#invokevirtual: 调用虚方法,运行期动态查找的过程。
#invokedynamic: 动态调用方法。
#invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的那个对象的特定方法
1.静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其他方法都是虚方法。

2、可见性问题

代码举例:

public class VolatileDemo01 {
    public static boolean stop = false;
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("rs:" + i);
        });
 
        thread.start();
        Thread.sleep(10000);
        int a=0;
        stop = true;
    }
}

3、有序性问题

代码举例:

public class OrderDemo {
    private static int i = 0;
 
    public static void main(String[] args) throws InterruptedException {
        for(;;){
            new Thread(()->{
                add();
            }).start();
            new Thread(()->{
                dec();
            }).start();
            System.out.println(i);
        }
    }
 
    public static void add(){
        i++;
    }
 
    public static void dec(){
        i--;
    }
}

二、Synchronized

一个从根源上解决问题的男人,它将并发操作限制成了串行操作

1、怎么使用

synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

2、存储在哪

  • 在我们最常用的虚拟机 hotspot 中对象在内存中的分布可以分为三个部分:对象头(Header)、实列数据(Instance Data)、对其填充(Padding)
并发编程之线程安全插图
  • 类元信息是用来存这个对象是属于哪个类的
  • 实例数据就是实例数据(嘿嘿)
  • 对齐填充是为了保证这个对象的长度一定是8字节(32位)或者16字节(64位)的整数倍
并发编程之线程安全插图2

3、升级流程

并发编程之线程安全插图4
  • 1. 锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁
  • 2. 在JDK6.0之前(不包括1.6),它是重量级,1.6引用锁升级的概念
    • 在 synchronized最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这也是在JDK6以前synchronized效率低下的原因。
    • JDK 1.6后,Jvm对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别)。
并发编程之线程安全插图6

1)偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距,如果线程间存在锁竞争,会带来额外的锁撤销的消耗,但是只会竞争一次,所以适用在一个线程访问同步代码块,但是这样的业务估计就没有,所以默认也关闭了,因为就算有,也都会上升到轻量级锁。

在java15中已经默认禁用了偏向锁:https://openjdk.org/projects/jdk/15/

并发编程之线程安全插图8

2)轻量级锁

  • 当代码执行到同步代码块时,如果同步对象没有被锁定也就是锁标志位为01 状态,那么虚拟机首先将在当前线程的栈帧中建立一个名为锁记录Lock Record 的空间
  • 这块锁记录空间用来存储锁对象目前的 Mark Word 的拷贝,如下图所示,这是在CAS 操作之前堆栈与对象的状态
并发编程之线程安全插图10
  • 当复制结束后虚拟机会通过CAS 操作尝试把对象的Mark Word 更新为指向Lock Record 的指针,如果更新成功则代表该线程拥有了这个对象的锁,并且将Mark Word 的锁标志位(最后两个比特)转变为 “00”,此时表示对象处于轻量级锁定状态,此时的堆栈与对象头的状态如下:
并发编程之线程安全插图12
  • 如果上述操作失败了,那说明至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机会首先检查对象的Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有了这个对象的锁,那么直接进入同步代码块执行即可。否则则说明这个对象已经被其他线程抢占了。
  • 如果有超过两条以上的线程争夺同一个锁的情况,那么轻量级锁就不再有效,必须膨胀为重量级锁,锁的标记位也变为“10”,此时Mark Word 中存储的就是指向重量级锁的指针,等待的线程也必须进入阻塞状态

那么轻量级锁针对于偏向锁的主要不是之前的一次竞争,而是会自旋竞争,所以会消耗cpu,但是比起后面的重量级锁性能还是快很多,毕竟不会阻塞线程

在jdk1.6以前,默认轻量级锁自旋次数是10次,如果超过这个次数或自旋线程数超过CPU核数的一半,就会升级为重量级锁!当然次数也可以更改,-XX:PreBlockSpin

jdk1.6以后加入了自适应自旋锁,自旋的次数不再固定,由jvm自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

  • 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋等待持续相对更长时间。
  • 对于某个锁对象,如果自旋很少成功获得过锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

轻量级锁使用自旋到一定次数后还没有拿到锁,10次,就会升级为重量级锁,线程阻塞

3)重量级锁

并发编程之线程安全插图14
  • 重量级锁也就是上述几种优化都无效后,膨胀为重量级锁,通过互斥量来实现,我们先来看下面的代码
  • 上面代码是一个简单使用了synchronized 的代码,我们通过字节码工具可以看到右侧窗口。我们发现,在同步代码块的前后分别形成了monitorenter 和 monitorexit 两条指令
  • 在Java对现中都会有一个monitor 的监视器,这里的monitorenter 指令就是去获取一个对象的监视器。而相应的monitorexit 则表示释放监视器monitor 的所有权,允许被其他线程来获取
  • monitor 是依赖于系统的 MutexLock (互斥锁) 来实现的,当线程阻塞后进入内核态事,就会造成系统在用户态和内核态之间的切换,进而影响性能
  • 重量级对象hashcode存储在ObjectMonitor

三、锁的问题

1、死锁

1)死锁

死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

这四个条件必须同时满足才会出现死锁:

  • 互斥:共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待:线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占:其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

2)活锁

活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

3)饥饿

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。

Java 中导致饥饿的原因:

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。

2、线程通信

  • 1. wait/notify notifyall
  • 2. lockSupport.park/unpark
  • 3. condition
  • 4. volatile
  • 5. coutdownlatch

四、Volatile

说白了就两个作用,解决可见性问题和有序性问题

上面讲线程问题的时候讲到了可见性问题,那怎么解决呢?

代码演示:

public class VolatileDemo02 {
    //    public static boolean stop = false;
    public volatile static boolean stop = false;//3、加个volatile也可以结束
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
//               try {
//                   Thread.sleep(5);
//               } catch (InterruptedException e) {
//                   e.printStackTrace();
//               }//2、睡眠一下也可以结束
//               System.out.println("rs:"+i);//1、打印放在循环里面也可以结束
            }
            System.out.println("rs:" + i);
        });
        thread.start();
        Thread.sleep(1000);
        stop = true;
    }
}

代码里面提供了三种方案:

  • 1. 打印放在循环里面也可以结束
    • 里面存在synchronized关键字
  • 2. 循环里面睡眠一下也能结束
    • 导致时间片主动释放,释放后线程进入到等待状态,下次获得时间片之后,缓存数据会与内存同步。
  • 3. 通过volatile关键字

1、硬件层面

1)CPU计算速度>>内存IO速度>>IO设备IO速度

原始问题:CPU计算速度>>内存IO速度>>IO设备IO速度。

解决方案:为了解决速度不匹配问题,硬件工程师设计了高速缓存

并发编程之线程安全插图16

缓存包含L1(L1d-数据缓存(存放数据)、L1i指令缓存(执行数据的指令码))、L2、L3,其中L3是CPU共享,而L1和L2是每个cpu独占的缓存空间。这三级缓存都是集成在CPU内的缓存结构,他们的作用就是为CPU和主内存之间提供一个高速缓冲区,L1最靠近CPU核心;L2其次;L3再次。运行速度方面:L1最快、L2次快、L3最慢;容量大小方面:L1最小、L2较大、L3最大。CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。

2)可见性问题

导致问题:可见性问题

解决方案:总线锁和缓存锁(MESI)

  • 1. M(Modify) 被修改 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
  • 2. E(Exclusive) 独享的 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • 3. S(Shared) 共享的 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  • 4. I(Invalid) 无效的 表示缓存已经失效,不过读或者写都要从主内存和别的缓存读取数据。

3)性能问题

导致问题:性能问题

解决方案:写缓存(Store Buffer)跟无效化队列(Invalidate Queue),使得活儿又变成了异步操作

导致问题:指令重排

并发编程之线程安全插图18
executeToCPU0(){
a=1;
    b=1;
}
executeToCPU1(){
    while(b==1){
        assert(a==1)
    }
}

4)重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

并发编程之线程安全插图20

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

5)指令重排问题

指令重排问题来了

解决方案:没办法了,整来整去全是问题,回到最开始的MESI算了,通过内存屏障来禁用写缓存和无效队列

写屏障(Store Memory Barrier):强制将Store Buffer中的内容写入到缓存中或者将该指令之后的写操作写入store buffer直到之前的内容被刷入到缓存中,也被称之为smp_wmb 读屏障(Load Memory Barrier):强制将Invalidate Queue中的内容处理完毕,也被称之为smp_rmb

全屏障(Fence Memory Barrier)

2、软件层面

1)volatile的作用

  • 1. 禁止编译器对代码进行某些优化
  • 2. lock汇编指令,锁住缓存行,启动内存屏障,禁止指令重排,保证有序性与可见性

当然,除了volatile来保证可见性外,我们从JDK1.5开始就引用了happens-before的概念来保证可见性!!

2)Happens-Before原则

happens-before是JMM定义的2个操作之间的偏序关系:如果操作A线性发生于操作B,则A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作具有顺序性,此时不能够随意的重排序。否则,无法保证顺序性,就能进行指令的重排序。

五、Happens-Before原则

1、程序顺序规则

一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial。as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变。

  • 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果。
  • 对于没有依赖关系的指令,即便是重排序,也不会改变在单线程环境下的执行结果。

具体来看下面这段代码,A和B允许重排序,但是C是不允许重排,因为存在依赖关系。根据as-if-serial语义,在单线程环境下, 不管怎么重排序,最终执行的结果都不会发生变化。

int a=2; //A
int b=2; //B
int c=a*b; //C

2、传递性规则

仍然看下面这段代码,根据程序顺序规则可以知道,这三者之间存在一个happens-before关系。

int a=2; //A
int b=2; //B
int c=a*b; //C
  • A happens-before B。
  • B happens-before C。
  • A happens-before C。

这三个happens-before关系,就是根据happens-before的传递性推导出来的。很多同学这个时候又有疑惑了,老师,你不是说,A和B之间允许重排序吗?那是不是A happens-before B不一定存在,也可能是B可以重排序在A之前执行呢?

没错,确实是这样,JMM不要求A一定要在B之前执行,但是他要求的是前一个操作的执行结果对后一个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一致,这种情况下,是允许重排序的。

3、volatile变量规则

对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排,这个规则前面已经分析得很透彻了,所以没什么问题,我们再来观察如下代码,基于前面两种规则再结合volatile规则来分析下面这个代码的执行顺序,假设两个线程A和B,分别访问writer方法和reader方法,那么它将会出现以下可见性规则。

public class VolatileExample {    
    int a=0;    
    volatile boolean flag=false;    
    public void writer(){        
        a=1;            //1        
        flag=true;      //2    
    }    
    public void reader(){        
        if(flag){    //3            
            int i=a;    //4        
        }    
    }
}
  • 1 happens before 2、 3 happens before 4, 这个是程序顺序规则
  • 2 happens before 3、 是由volatile规则产生的,对一个volatile变量的读,总能看到任意线程对这个volatile变量的写入。
  • 3 happens before 4, 基于传递性规则以及volatile的内存屏障策略共同保证。

那么最终结论是,如果在线程B执行reader方法时,如果flag为true,那么意味着 i=1成立。

并发编程之线程安全插图22

这里有同学可能会有疑问说,老师,你前面讲的程序顺序规则中,在单线程中,如果两个指令之间不存在依赖关系,是允许重排序的,也就是1 和 2的顺序可以重排,那么是不是意味着最终4输出的结果是0呢?

这里也是因为volatile修饰的重排序规则的存在,导致1和2是不允许重排序的,在volatile重排序规则表中,如果第一操作是普通变量的读/写,第二个操作是volatile的写,那么这两个操作之间不允许重排序。

并发编程之线程安全插图24

4、监视器锁规则

一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作。

int x=10;
synchronized (this) { // 此处自动加锁 
    // x 是共享变量, 初始值 =10 
    if (this.x < 12) {    
        this.x = 12;   
    }  
} // 此处自动解锁

假设x的初始值是10,线程A执行完代码块后,x的值会变成12,执行完成之后会释放锁。 线程B进入代码块时,能够看到线程A对x的写操作,也就是B线程能够看到x=12。

5、start规则

如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。

public class Test{
    public static void main(String[] args){
        final int x[]={0};
        Thread t1 = new Thread(()->{
            // 主线程调用 t1.start() 之前
            // 所有对共享变量的修改,此处皆可见
            // 此例中,x==10
            System.out.println(x[0]);
        });
        // 此处对共享变量 x修改
        x[0] = 10;
        // 主线程启动子线程
        t1.start();
    }
}

6、join规则

join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。

public class Test{
    public static void main(String[] args){
        final int[] x = {0};
        Thread t1 = new Thread(()->{
            // 此处对共享变量 x 修改
            x[0] = 100;
        });
        // 例如此处对共享变量修改,
        // 则这个修改结果对线程 t1 可见
        // 主线程启动子线程
        t1.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 子线程所有对共享变量的修改
        // 在主线程调用 t1.join() 之后皆可见
        // 此例中,x==100
        System.out.println(x[0]);
    }
}

发表评论