多线程的三大特性以及Java中如何保证线程安全

Java工程师基本都知道,很多Java程序在单线程和多线程环境中运行往往会得到不同的结果,多线程有三大特性:可见性有序性原子性,要保证程序在并发条件下正确执行,就需要保证上述三个特性,缺少一个都有可能导致程序运行不正确。
那么多线程的三种特性具体是什么以及在Java中是如何保证以上特性的呢?

1、可见性:

所谓可见性,就是当一个线程修改了某个线程共享变量的值,其他线程能够立即得知这个修改。为什么其他线程一般情况下无法立即感知变量值的修改呢?原因是CPU和内存之间是有缓存的,正是缓存的存在,使得任一线程修改的数据不会立即刷新到主内存。

缓存

下图是多核CPU与内存交互的示意图
在这里插入图片描述
上图中,ALU是算术逻辑单元,Registers(寄存器),ALU会从寄存器中直接读取数据进行计算,而寄存器中的数据来自内存。由于相比于CPU的计算速度来说,内存的数据读取速度还是远远太慢,因此有必要在寄存器和内存之间加上缓存区,以提升效率。在CPU设计中,目前三级缓存最为普及,即在寄存器和内存之间会设计三级缓存,分别为L1 Cache、L2 Cache、L3 Cache,其中L3缓存在同一个CPU中的多核之间共享。当CPU需要读取数据时,首先从L1 Cache中查找,如果没有就从L2 Cache中查找,如果还是没有则从L3 Cache中查找,最后是内存。当CPU要往内存中写数据时,也是一样,会往缓存中一级一级写入直到写入主内存。

缓存一致性协议

CPU在读取内存中的某个值到缓存中时,会按块进行读取,即连带这个值附近的值也一起读进缓存,目的是减少读取数据的次数,提高效率(因为一般情况下内存中某个值被使用时,它的邻居也很大可能性需要使用)。而一次性读取的这个数据块就称为缓存行(cache line),它的大小为64 bytes(64bit JDK)。如下图,x,y在内存中相邻,它们会一起被读进缓存行:
在这里插入图片描述
而缓存一致性协议,就是规定当一个线程(其实是一个核)修改了缓存行中的某个值,会立即刷新到主内存,同时通知其他线程缓存中的缓存行数据无效,需要重新从内存中读取。这是一个协议,而不同的CPU厂商都有自己的实现,其中著名的有Intel的MESI协议,其实都是为了保证多核之间数据的可见性。

2、有序性

CPU在执行指令时,如果发现两条指令的执行先后顺序不会影响最终结果,就有可能会乱序执行,也就是指令重排序 。
CPU为什么会乱序执行?其实就是为了提高效率,但是乱序执行的前提是两条指令交换顺序不会破坏最终一致性。如何证明CPU在执行程序时会发生乱序执行呢?由于在单线程中,CPU执行程序必然会保证最终一致性,那么最佳的情况便是在多线程条件下证明,下面有个经典的案例:

public class Disorder {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            CountDownLatch latch = new CountDownLatch(2);

            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
                latch.countDown();
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
                latch.countDown();
            });
            t1.start();
            t2.start();
            latch.await();
            // 判断x==0&&y==0的情况是否会出现
            if (x == 0 && y == 0) {
                System.out.println("第" + i + "次(" + x + "," + y + ")");
                break;
            }
        }
    }
}

上述代码做了这么一件事:有x,y,a,b 4个线程共享变量,线程1执行a=1;x=b;线程2执行b=1;y=a;两个线程不断执行以上代码,如果程序都是按顺序执行,那么x和y的结果只可能是以下几情况:
在这里插入图片描述
即(x=0,y=1),(x=1,y=1),(x=1,y=0),x0&&y0的情况不可能会出现,但是程序运行结果如下:
在这里插入图片描述
这种情况竟然出现了,也就是必然会出现了x=b先于a=1执行或者y=a先于b=1执行,很有可能出现了如下情况:
在这里插入图片描述
这个案例利用举反例的方法证明出程序的乱序执行。

3、原子性:

原子性是指一个操作或者一系列操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。如果一系列操作不能保证原子性,那么在多线程环境下就很有可能出现问题。例如在Java中很常见的操作:i++,实际上这条代码在CPU内部执行时并不具备原子性,这段代码底层执行会分三步,获取i的初始值,将获取到值+1,将新的值赋给i,由于不具备原子性,以上三步操作在多线程中随时可能会被其他线程打断,导致赋的值不正确(多个线程执行i++,一般来说i的最终值会比预想的要小)

4、Java中如何保证可见性、有序性和原子性

volatile

Java中提供了一个关键字volatile,这个关键字能够保证线程可见性和禁止指令重排序

volatile保证可见性

volatile其实就是利用了缓存一致性协议,一旦某个变量被volatile修饰,那么一旦这个变量被线程修改,其他线程锁保持的该变量的缓存就会失效,需要从内存中获取最新值。如下案例:

public class TestVolatile {
    private static /*volatile*/ boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (true) {
                if (flag) {
                    break;
                }
            }
            System.out.println("m end");
        }).start();

        // 1s后由另一个线程修改flag的值
        Thread.sleep(1000);
        flag = true;
    }
}

两个线程共享变量flag,其中一个线程在做无限循环,直到flag为true时跳出循环,1s后另一个线程将flag改为true,如果flag不加volatile修饰,执行程序会发现程序停不下来。而如果用volatile修饰flag,程序大约会在1s后结束并打印“m end”。

volatile禁止指令重排序

利用volatile来禁止指令重排序的一个经典案例就是双重检查锁单例(DCL),代码如下:

class Singleton{
    private volatile static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

双重检查锁通过两次判空以及加synchronized锁来实现单例对象的创建,为了降低锁的粒度,只在创建对象的核心代码块外加synchronized。线程进入getInstance方法后,先判断singleton是否为null,如果为null,则尝试获取锁,如果获取到了锁,则进入synchronized代码块,此时第二次判断singleton是否为null,原因是可能会有多个线程同时进入了第一个if条件,第二个if判断能够保证只有一个线程能够创建对象。
单例模式不是这里重点,重点是静态变量singleton前面的修饰符为什么一定要加上volatile?下面有一个最简单的创建对象的代码:

public class JustTest {
    public static void main(String[] args) {
        T t = new T();
    }
}

class T {
    int i = 10;
}

利用IDEA的show bytecode工具可以看到main方法的字节码如下:
在这里插入图片描述
其中创建T对象的关键三步:
0 new #2 <com/thread/T>
4 invokespecial #3 <com/thread/T.>
7 astore_1
第一步new代表为对象分配内存,期间会将成员变量都设为默认值,成员i=0,此时对象还处于半初始化状态;第二步是执行构造方法,这一步会初始化成员变量,为成员变量赋予正确的值,此时i=10;第三步astore_1会将创建好的对象与栈中的变量t建立连接,也就是将t指向对象地址。
这个时候再看DCL代码,结合前面分析的,在创建Singleton对象的过程中,指令可能会发生重排序,第一步分配内存必然先执行,但是invokespecial #3 <com/thread/T.>和astore_1的执行顺序可能会颠倒,如果astore_1先执行,那么在很短的一段时间内变量singleton就会指向一个还未完全初始化的半初始化对象,而这个半初始化对象是在内存中实际存在的,它!=null,假如此时另一个线程进入方法判断singleton==null不成立,就会将对象返回,如果对象被用于其他重要用途,则可能会带来严重的问题,因此为了规避指令重排序带来的将半初始化对象返回的问题,必须在单例对象前加修饰符volatile。
Java是如何禁止指令重排序的呢?
JVM通过给指令前后加上内存屏障,屏障两边的指令不可以重排序,保证有序。

内存屏障
JVM规定,在对volatile修饰的内存区域进行读写时,需要加屏障,也就是会在两条指令之间加一层特殊的屏障,阻止两条指令交换执行顺序,Java中的JSR规范规定的内存屏障有以下四种:
在这里插入图片描述
上面几种屏障的大致意思就是在写操作或读操作前后加上屏障,等完全执行完毕之后才可以进行下一步操作,也就是保证了指令必须有序执行。
在这里插入图片描述
这里的内存屏障只是Java的规定,不同版本的JVM可能会有不同的实现,例如hotspot虚拟机则是利用CPU的自带指令lock,lock指令用于在多处理器中执行指令时对共享内存的独占使用,它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。另外还提供了有序的指令无法越过这个内存屏障的作用。下图是hotspot中关于内存屏障的实现源码:
在这里插入图片描述

synchronized

Java中保证原子性的最有效方案就是同步,通过加锁的方式实现一系列操作同一时刻只能被一个线程执行,而加锁的最直接方式就是加synchronized关键字,synchronized在Java早期是一把重量级锁,但是随着Java的发展,如今效率已经很高。i++不具备原子性,但是如果在i++操作外加上synchronized,保证每次只有一个线程能修改i,就能保证i++操作在并发环境下的安全性。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值