当前位置: 主页 > JAVA语言

java 线程安全解决方案-java线程池安全停止

发布时间:2023-02-08 16:31   浏览次数:次   作者:佚名

线程编程bug的来源

cpu、内存、I/O设备不断迭代,朝着更快的方向努力。 但是,在这种高速发展的过程中,始终存在一个核心矛盾,那就是三者的速度差异。 CPU > 内存 > i/0。 根据木桶理论,程序的整体性能取决于最慢的操作——I/O设备的读写,也就是说单方面提高CPU性能是无效的。 为了平衡三者的速度差异,计算机体系结构、操作系统、编译器都做出了贡献,主要体现在:

cpu加了cache来平衡与内存的速度差异; 操作系统通过添加进程和线程及时复用cpu,进而平衡cpu和i/o设备之间的速度差异; 编译器优化指令的执行顺序,使缓存的使用更加合理。 CPU 缓存 - 可见性问题

单核时代,所有线程都运行在一个CPU上,CPU缓存和内存的数据一致性很容易解决,因为所有线程都操作同一个CPU缓存,一个线程写缓存,另一个线程写缓存缓存。 说一定要看得见。 当一个线程修改共享变量时,另一个线程可以立即看到它,我们称之为可见性。

多核时代,每个CPU都有自己的缓存。 这时候CPU缓存和内存的数据一致性就没那么容易解决了。 当多个线程运行在不同的 CPU 上时,这些线程运行在不同的 CPU 缓存上。 假设线程a运行在cpu-1上,线程b运行在cpu-2上。 此时线程a对变量v的操作对于线程b是不可见的。

public class Test{
    private long count = 0;
    private void add(){
        int i = 0;
        while(i++ <= 10000){
            count += 1;
        }
    }
    
    public static long calc(){
        final Test test = new Test();
        Thread t1 = new Thread(()->{
            test.add();
        });
        
        Thread t2 = new Thread(()->{
            test.add();
        });
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        retun count;
    }
}
复制代码

上面的程序,如果在单核时代,结果无疑是20000,但是在多核时代,最后的结果就是10000-20000之间的一个随机数。 我们假设t1和t2线程同时开始执行,那么它们会第一次将count=0读入各自的cpu缓存中,执行完count+=1后,各自cpu缓存中的值都为1 ,不是我们预期的 2、之后每个cpu缓存都有一个count值,所以最后的count计算结果小于20000,这是缓存的可见性问题。

线程怎么解决死锁_java线程池安全停止_java 线程安全解决方案

线程切换——原子性问题

Java并发程序是基于多线程的,多线程也涉及到线程切换。 执行count += 1的操作,至少需要3条cpu指令:

首先,需要将变量 count 从内存加载到 cpu 寄存器。 对寄存器执行 +1 操作。 将结果写入内存。 缓存机制可能会导致写入CPU的缓存,而不是内存。

以上3条指令,如果线程a刚刚执行完指令1,与线程b发生线程切换,导致count的值为0,而不是+1之后的值,结果就不是我们的了。 希望2。

我们将CPU执行过程中一个或多个操作不被中断的特性称为原子性。

java线程池安全停止_线程怎么解决死锁_java 线程安全解决方案

编译执行-顺序问题

顺序是指程序按照代码的顺序执行。 但是,为了优化性能,编译器有时会改变程序中语句的顺序。 例如程序:“a=6;b=7”,编译优化后的序列可能是:“b=7;a=6;”。 java中最经典的案例就是单例模式,通过双重检查创建单例对象来保证线程安全。

public class Singleton{
    static Singleton bean;
    static Singleton getInstance(){
        if(bean == null){
            synchronized(Singleton.class){
                if(bean == null){
                    bean = new Singleton();
                }
            }
        }
        return bean;
    }
}
复制代码

假设两个线程ab同时调用了getInstance()方法,所以同时锁定了Singleton.class。 这个时候jvm保证只有一个线程能加锁成功。 如果是a,那么b就会处于等待状态。 当a执行完,释放锁后,b被唤醒,继续执行,发现已经有bean对象,于是直接返回。 我们以为jvm创建对象的顺序是这样的:

分配一块内存m; 在内存 m 上初始化单例对象; 然后将m的地址赋值给bean变量;

但实际上优化后的执行路径是这样的:

分配一块内存m; 将 m 的地址分配给 bean 变量; 在内存M上初始化单例对象;

当线程a第一次执行getinstance()方法时,执行指令2时,发生线程切换,切换到线程b。 如果此时线程B也执行了getinstance方法,那么线程B在执行第一个判断的时候,会发现bean!=null,直接返回。 如果我们此时访问bean对象,就会报空指针异常。

java 线程安全解决方案_线程怎么解决死锁_java线程池安全停止

实际上,在代码的编译和执行过程中,有以下三种情况可能会导致指令重排

由于编译优化而重新排序 由于 CPU 指令的并行执行而重新排序 由于硬件内存模型而重新排序 由于 Java 内存模型而重新排序

从上面的分析来看,CPU缓存导致可见性问题,线程切换导致原子性问题,编译执行导致有序性问题。 那么如何解决这三个问题呢? 很直接的做法就是禁止使用CPU缓存,禁止线程切换,禁止指令重排。 但是CPU缓存、线程切换、指令重排都是为了提高代码运行效率,但是为了保证多线程编程不会出现问题,过分禁止使用这些技术也会影响效率代码执行,于是java内存模型应运而生。 生java 线程安全解决方案,对应的规范是JSR-133。 之所以叫java内存模型,是因为要解决的问题都与内存有关。

Java内存模型解决多线程的三个问题,主要靠三个关键字和一个规则。 三个关键字是:volatile、synchronized、final,一个规则是:happens-before规则。

易挥发的

volatile 关键字可以解决可见性、排序和部分原子性问题。

volatile 如何解决可见性问题

对于用volatile修饰的变量,在编译成机器指令时,会在write操作后加上一条特殊的指令:“lock addl #0x0, (%rsp)”,这条指令会改变CPU对这个变量的修改,立即写入到内存,并通知其他CPU更新缓存数据。

volatile如何解决顺序问题

禁止指令重排序分为完全禁止指令重排序和部分禁止指令重排序。 完全禁止指令重排是指被volatile修饰的变量的读写指令不能和前面的读写指令重排,也不能和后面的读写指令重排。

java线程池安全停止_线程怎么解决死锁_java 线程安全解决方案

指令重排是为了优化代码执行效率,过于严格地限制指令重排会明显降低代码执行效率。 因此,Java内存模型将volatile的语义定义为:部分禁止指令重排序。

Java内存模型在对volatile修饰的变量进行写操作时,只禁止其前面的读写操作重排序,后面的读写操作可以与其进行指令重排序。

Java内存模型在对volatile修饰的变量进行读操作时,只禁止对其后面的读写操作进行重排序,其前面的读写操作可以与其进行指令重排序。

java线程池安全停止_线程怎么解决死锁_java 线程安全解决方案

为了实现上述细化指令重排的禁止规则,Java内存模型定义了四种细粒度的内存屏障(Memory Barrier),也称为内存栅栏(Memory Fence),分别是:StoreStore、StoreLoad、LoadLoad、加载存储。

# other ops
[StoreStore]
x = 1 # volatile修饰x变量,volatile写操作
[StoreLoad]
y = x # volatile读操作
[LoadLoad]
[LoadStore]
# other ops
复制代码

java线程池安全停止_线程怎么解决死锁_java 线程安全解决方案

volatile如何解决一些原子性问题

原子性问题分两类,一类是读写64位long和double类型数据的原子性问题,一类是自增语句(比如count++)的原子性问题。 volatile可以解决第一类原子性问题,不能解决第二类原子性问题。

同步的

synchronized 也可以解决可见性、顺序和原子性的问题。 但是它的解决方案比较简单粗暴,让原本并发执行的代码串行执行,每次加锁和释放锁的时候都会同步CPU缓存和内存中的数据。

最终的

用final修饰一个变量,本意是告诉编译器:这个变量本质上是不变的,可以优化。 这导致1.5版本之前做了很多优化,以至于所有的优化都是错误的。 双检索方法创建了一个单例,构造函数的不正确重新排序导致线程看到最终变量值的变化。 但是在1.5之后,final修饰的变量重排被限制了。

发生之前

概念:前一个操作的结果对后续操作可见。 换句话说,happens-before 约束了编译器的优化行为。 虽然允许编译器进行优化,但是要求编译器在优化后必须遵守happens-before原则。

happens-before 一共有六个规则:

顺序规则

先前的操作发生在任何后续操作之前。

比如上面的代码x=42happens-before是在v=true;中,比较符合单线程的思想,程序之前对某个变量的修改必须对后续操作可见。

volatile 变量规则

对 volatile 变量的写入操作发生在该变量的后续读取操作之前。 不管怎么看,都是禁用缓存的意思。 似乎语义在1.5版本之前没有改变。 这时候我们需要关联下一条规则才能看到这条规则。

传递性

a happens-before b,b happens-before c,然后 a happens-before c。

x = 42 happens-before 写入变量 v = true; ——规则 1 写入变量 v = true happens-before 读取变量 v==true。 ---规则 2 所以 x = 42 happens-before read variable v == true; ---规则 3

如果线程b读到v=true,那么线程a设置的x=42对线程b可见。 也就是说线程b可以看到x=42。

监视器中的锁定原理

锁的解锁发生在后续锁的锁定操作之前。 Synchronized是java中monitor的实现。

监视器中的锁是在java中隐式实现的。 比如下面的代码在进入同步块之前会自动加锁,代码块执行完后会自动释放锁。 加锁和解锁的操作都是编译器帮我们实现的。

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

根据monitor中lock的原理,线程a执行完代码块后,x的值变为12。当线程B进入代码块时,可以看到线程a对x的写操作,也就是线程b可以看到x=12。

开始()

主线程a启动子线程b后,子线程B在启动子线程b之前可以看到主线程a的运行情况。 也就是说,线程 b 在线程 a 中启动,然后 start() 操作发生在线程 b 中的任何操作之前。

int x = 0;
Thread B = new Thread(()->{
    // 这里能看到变量x的值,x = 12;
});
x = 12;
B.start();
复制代码

加入

主线程a等待子线程b完成(主线程a调用子线程b的join方法实现),当b完成(主线程a中的join方法返回),main线程a可以看到子线程b的任何操作。 以下是对共享变量的所有操作。

如果线程b的join()在线程a中被调用并成功返回,那么线程b中的任何操作happens-before join操作的返回。

int x = 0;
Thread b = new Thread(()->{
    x = 11;
});
x = 12;
b.start();
b.join();
// x = 11;
复制代码

线程中断规则

线程a调用线程b的interrupt()方法,happens-before线程b的代码检测到中断事件的发生。

对象终结规则

一个对象被初始化,happens-before 它的 finalize() 方法调用

CPU 缓存一致性协议和可见 CPU 缓存一致性协议

目的是保证不同CPU之间缓存数据的一致性。 比较经典的一致性协议是MESI协议。

缓存行有 4 种不同的状态:

下面通过一个例子来更深入地理解MESI cache line的四种不同状态的传输方式。 比如我们有3个CPU,分别是CPU0、CPU1、CPU2,初始变量V=1。

线程怎么解决死锁_java线程池安全停止_java 线程安全解决方案

第一步,CPU0读取V,CPU0 Cache中V=1,状态为E,其余CPU Cache没有数据。 第二步,CPU0更新V=2,CPU0 Cache中V=2,状态为M,Memory中V=1。 其余CPU Cache没有数据 Step 3,CPU1读取V,先通过总线向其他CPU广播读取请求,CPU0收到通知后,状态为Mjava 线程安全解决方案,更新数据需要更新到内存中,然后状态变为Only S can response to CPU1的读请求,将CPU1 Cache中的cache line改为S。 第四步,CPU2读取V,同第三步。 第五步,CPU2更新V,因为CPU2的cache line的状态是S,如果需要修改V,需要广播其他的cache line状态为S的CPU Cache,改变对应的cache line的状态在其他CPU上发给I,回复invalidate ack报文给CPU2。 CPU2收到invalidate ack报文后,更新数据V=3,同步更新到主存。 然后CPU2上的cache line的状态变成E。 第六步:CPU0读取发现自己的CPU cache line的状态是I,于是CPU0先广播读请求,CPU1不处理,CPU2改变状态缓存行到S,CPU0从内存中读取并更新缓存行状态为S。Store Buffer

从上面第五步我们可以知道,当多个CPU共享同一个数据时,其中一个CPU需要广播invalidate消息来更新数据,其他CPU收到invalidate消息后会将cache line的状态变为I invalidate报文,然后返回invalidate ack报文给CPU,然后CPU收到invalidate ack报文就可以更新数据,同步更新到内存中。 这是一个非常耗时的操作。 需要等待CPU完成写操作才能执行其他指令,这会影响CPU的执行效率。 因此,计算机科学家们在CPU和CPU缓存之间加入了Store Buffer来完成异步写操作。

CPU将写操作信息存入Store Buffer后,CPU就可以执行其他操作指令了。 Store Buffer负责广播invalidate消息,接收invalidate ack消息,写入缓存和内存。

读取消息时,也是先从Store Buffer中获取。 如果没有Store Buffer,则从缓存和主存中获取。

无效队列

Store Buffer向其他CPU发送invalidate消息后,需要等待其他CPU设置cache invalidation并返回invalidate ack消息,才能执行写cache和内存的操作。 但是其他CPU可能正忙于执行其他指令,所以Store Buffer写缓存和内存操作不及时,大量的写操作信息存储在Store Buffer中。

你可能认为可以扩展Store Buffer的存储空间,让Store Buffer存储更多的写操作信息。

计算机科学家在CPU Cache和总线之间设计了一个Invalidate Queue,用于存储invalidate消息并返回invalidate ack消息,并异步执行cache line status设置为I的操作。

CPU 缓存一致性协议和可见性

如果没有Store Buffer和Invalidate Queue,那么缓存一致性协议可以保证各个CPU缓存之间的数据一致性,不会有可见性问题。 但是引入Store Buffer和Invalidate Queue异步执行写操作后,即使使用缓存一致性协议,CPU缓存之间仍然会存在瞬态数据不一致,即瞬态可见性问题。

java线程池安全停止_java 线程安全解决方案_线程怎么解决死锁

可见性示例:

CPU0和CPU1都将内存中的数据a=1读取到各自的缓存中,对应的缓存行状态标记为S(shared)。 CPU0执行写操作a=2。 CPU0为了提高写入速度,将写入操作a=2存放在Store Buffer中,并立即返回。 假设此时Store Buffer还没有完成写缓存和内存操作。 CPU0读取数据时,直接从Store Buffer中获取a=2。 CPU1读取数据,发现Store Buffer中没有数据,于是从缓存中读取a = 1。 这时候缓存数据就不一致了。 假设Cpu0的Store Buffer会向Cpu1的Invalidate Queue发送消息。 在Invalidate Queue更新失效信息到Cpu1的缓存之前,Cpu1仍然无法读取到最新的值a=2。

从写入Store Buffer的写操作到Invalidate Queue根据失效信息设置CPU缓存行状态为I的这段时间,多个CPU之间的缓存数据可能会出现暂时的不一致。