《2022问》1:为什么多线程会有可能不安全

        似乎,只要谈到多线程,都会跟安全或不安全联系起来。但是为什么多线程就会有可能出现不安全的问题呢?为什么 Java 语言的设计就不能让多线程的时候必然安全呢?这样我们不就省了很多事吗?事实是,Java 并不会无感保证多线程安全,这就使得程序员不得不去了解如何才能保证多线程安全的相关编码内容了。

        首先,什么叫做多线程不安全?这里面说的“不安全”指的是数据的不安全,是由于多线程的情况下,某些数据有可能出现脏读、脏写等不安全的问题。那么 Java 会在何种情况下出现脏读、脏写之类的问题呢?通常是对象内的成员变量(被 final 修饰的变量除外,这个很好理解,它要求在使用前就初始化了,后续不可变,自然不会不安全)被多个线程所访问的时候。

public class MainTest {
    public static void main(String[] args) throws InterruptedException {
        Obj obj = new Obj();

        Thread[] tArr = new Thread[10];
        for (int i=0; i<tArr.length; i++){
            tArr[i] = new Thread(new MyInit(obj));
            tArr[i].start();
        }
    }
}

class MyInit implements Runnable{
    private Obj obj;

    public MyInit(Obj obj){
        this.obj = obj;
    }

    @Override
    public void run() {
        for (int i=0; i<1000; i++){
            obj.add();
        }
        System.out.print(obj.get() + "\t");
    }
}

class Obj {
    private int i = 0;

    public int get() {
        return i;
    }

    public void add() {
        this.i++;
    }
}

        如上的代码模拟了10个线程并发进行的情况,每个线程都会对 obj 对象的成员变量 i 自增1000次的操作后输出 i 的当前值,输出的结果如下所示:

2114    2856    2024    4152    4676    5542    6542    7919    8383    9383

        每次输出的值都会不一样——这很好理解,由于并发的原因,同一时刻会有多个线程都会对 i 做自增操作,但是,为什么这里面输出的最大值不是10000呢?

        虽然存在着多个线程同时对单个变量进行操作,但是并不涉及其他的计算,这仅仅是让 obj 对象的 i 进行 i++ 的操作而已,这里面输出的最大值肯定是最后执行完的那个线程输出的吧?最终不应该是自增了10000次吗?为什么最大值不是10000呢?——这似乎会令不少人感到疑惑,如果 obj 对象的 i 在内存中只会被存储一份的话,那么结果确实会是10000,而不是9000多的某个数字。

        令人遗憾的是:i 并不是每个线程都能够直接操作到它的,在i与线程之间,还隔着一层“工作内存”——相当于缓存,既然存在着缓存,那么必然存在着缓存一致性的问题,需要正确理解并处理才能获得预想中的结果。

        这是 Java 的内存模型所决定的。简述一下原因:

        现代计算机的CPU速度通常比内存读写速度高几个量级,如果CPU每个动作直接作用在内存,那么必然导致非常多的IO等待——因为某个计算过程在CPU中可能瞬间就完成了,要把这个结果写入到内存中,这个写入过程对于CPU来说实在太慢了。于是现代计算机基本都会在CPU和内存之间引入一层或多层高速缓存,这个缓存的速度相比内存而言,更接近CPU。于是CPU第一次读取是从内存读取的,然后把读取结果放在缓存中,计算的结果也写到缓存中,缓存最终会写入内存中——这中间的缓存一致性问题会由缓存一致性协议解决。

        而 JVM 为了提高运行速度,也会利用到计算机的高速缓存。JVM 的内存模型分为了工作内存和主内存,其中的工作内存就是使用了计算机的高速缓存,所以它的速度会比主内存高很多,可类比计算机,JVM 执行时,第一次读取是从主内存读取的,随后的操作都在工作内存中进行,最终的结果会写回到主内存中——工作内存与主内存自然就存在着一致性问题了。

        

        如图所示,每个线程都会有单独的工作内存,互相不会互通,最终通过 Save、Load 操作作用到同一个主内存。

        上述的 Save、Load 操作并不会保证工作内存与主内存之间的一致性,或者说用“一致性”来描述过于模糊,准确点说是:在这个内存模型下,存在三个待解决的问题,分别是原子性、可见性和有序性。

原子性:某操作只需一行机器码完成 或 锁定某段代码使其不会并发进行

可见性:使“工作内存与主内存同步延迟”的情况不会发生

有序性:使代码不会发生指令重排,保证代码顺序即是执行顺序

        上面的代码之所以输出的最大值不是预料中的10000,就是因为每次i++的操作不具备原子性(虽然我们的代码只有一步,但是实际的机器码操作不止一步,这样在步骤之间有可能实际的值已经被其他线程更改了,而本线程依然使用旧值进行增1计算)并且不具备可见性(本线程更改后的i值只是在本线程的工作内存中,还没同步到主内存;或者其他线程的更改也没同步到主内存,本线程不能获取实际的最新的值),上述代码暂时不涉及有序性。

        对于这些问题,Java 提供了两个关键字以供开发者使用:volatile 和 synchronized。

volatile:保证可见性和有序性(volatile 的其中一个语义为禁止指令重排,从而保证了有序性),不保证原子性

synchronized:同时保证原子性、可见性和有序性(synchronized 的做法是对要操作的变量加锁,加锁的变量在同一个时刻只允许一个线程进行操作——这样,虽然它并没有禁止指令重排,但是只有一个线程进行操作的时候,是否存在指令重排其实并没有任何不良影响,可以认为有序性天然得到保证)

        讨论到这里可知,volatile 不能保证上述代码得到预期的结果,因为不保证原子性。volatile 的用途应该是不需要使用被修饰的变量进行任何计算的时候,应用范围并不广。synchronized 因为保证了原子性,也就是锁住的变量只能由一个线程操作,所以能够保证输出的最大值必然是10000,只需要更改成以下代码即可:

//    public void add() {
    public synchronized void add() {
        this.i++;
    }

        或许你已经发现了,synchronized 虽然几乎完美地保证了多线程下对变量的安全操作,但是它最突出的缺点也显而易见,即是性能会很低。因为它是系统级实现的线程串行化,第一个线程获取到锁后独占,其他线程则进入休眠,独占线程释放锁后通知休眠的线程唤醒,竞争到锁再继续执行——这个过程中,休眠和唤醒都涉及当前线程的上下文数据的现场保护、现场恢复,线程一多,这些上下文数据的维护会是一个非常大的负担。

        那么到底有没有一种办法,既解决了多线程的安全问题,又能够不牺牲掉这么多性能呢?还真的有,那就是 AQS(AbstractQueuedSynchronizer),从 JDK1.5 开始提供。

(不要问我“既然有那你前面还费字瞎比比那么多干啥?”,你这样会显得我很呆😳)

        AQS 的实现原理是使用 CPU 提供的 CAS 指令保证加锁时只会有一个线程加锁成功,加锁的对象的头部信息会加上标记,表示本对象被加了乐观锁(因为 CAS 跟乐观锁原理类似,为方便,后面称为乐观锁,同理,将 synchronized 加的锁称为悲观锁)。

        CAS 全称 Compare-and-Swap,即比较和交换,它允许两个值:比较值和交换值,比较值与当前的值比较,如果相同,则跟交换值交换并返回当前值,否则直接返回当前值——整个比较和交换的操作是原子性的,由 CPU 保证。这跟乐观锁原理类似,比较值是本线程读取到的值,如果比较时发现不相同则表示该值被其他线程更改了,否则表示未发生更改,可以使用交换值进行更新。

        而 AQS 在加锁前会先判断该对象是否存在乐观锁,不存在乐观锁时才会在加乐观锁的步骤前使用 CAS 来保证只会有一个线程加锁成功。

        ReentratLock 是 AQS 的一种实现,它的主要方法是 lock()、tryLock()、tryLock(long timeout, TimeUnit unit)、unlock();

lock() 方法:它是直接加锁,如果加乐观锁失败,则直接转为悲观锁 synchronized。没有返回值

tryLock() 方法:它是自旋加锁,即如果加乐观锁失败,则继续重试(多次重试的过程称为自旋),直到成功为止。没有返回值

tryLock(long timeout, TimeUnit unit) 方法:它是有尝试时间限制的自旋加锁,返回值为 boolean 类型。如果在 timeout 时间之内没有加上乐观锁,则返回false,成功则返回true

unlock() 方法:解锁

        ReentratLock 是可重入的,但是必须保证加锁的次数与解锁的次数相同。

        ReentratLock 与 synchronized 相比,ReentratLock 使用方式丰富,性能更优,但相对而言繁琐且需要手动除锁;synchronized 则相对简单便利,但存在 CPU 对多线程上下文的维护开销(ReentratLock 则不涉及线程切换的问题)。

        但是,但是才是重点,你可能从上面开始就开始觉得,既然 ReentratLock 这么溜,干嘛还非得讲半天 synchronized,虽然是用起来麻烦了一点,但只要我学会了,不就替代掉 synchronized 了吗?然鹅事情并没有这么简单,因为 ReentratLock 并不是设计来替代 synchronized 的

        举个简单的例子: ReentratLock 的 lock() 方法,如果加乐观锁失败,则直接转为悲观锁 synchronized,所以这个方法加的锁既有可能是乐观锁,也可能是悲观锁;当然你如果不喜欢它的不确定性,也可以使用 tryLock() 方法,但是,这也无法保证必然比 synchronized 高效,因为如果存在大量线程竞争锁的时候,tryLock() 一直自旋加锁的过程会存在大量的失败,即是某个线程一直都无法获得锁,因为每次加锁前的 CAS 操作都无法成功(被其他线程抢先了)——这是不公平锁的情况,如果是公平锁,也可能存在非常糟糕的情况:几乎每个线程都需要等待大量的时间才能成功。又或者使用有尝试时间限制的自旋加锁方法tryLock(long timeout, TimeUnit unit),也有可能导致大量的超时。当然,如果出现大量的并发时,synchronized 也可能存在类似的问题。

        可见,ReentratLock 并没有比 synchronized 高到哪里去,当使用不当时甚至有可能比 synchronized 更加糟糕。只有当并发量低的时候 ReentratLock 才比 synchronized 有更好的表现,所以我认为 ReentratLock 是低并发时的解决方案。

        说了半天,原来 ReentratLock 也不是万能的,那么到底有没有万能的解决方案呢?目前来看就只剩下 TreadLocal 了,它是每个线程都互相隔离的,自然就不存在竞争的问题了。显然,这种做法需要付出更多的内存消耗来实现,是空间换时间的做法。以下是简单的代码例子:

// 声明线程隔离的变量,变量类型通过泛型决定
private static ThreadLocal<Integer> localInt = new ThreadLocal<>();

// 获取泛型类的对象
Integer integer = localInt.get();

if (integer==null){
    integer = 0;
}

// 将泛型对象设到变量中
localInt.set(++integer);

         以上便是可能会发生多线程不安全问题的原因所在以及三种解决方案,分别是系统级别的同步线程锁(synchronized)、代码级别的AQS乐观锁(ReentratLock )以及线程隔离类(ThreadLocal)。并且就 Java 这门语言而言,多线程下的安全问题并不是语言本身天然保证的,目的是为了提高代码的执行速度,于是开发人员必须学习并掌握其中的缘由以及保证多线程安全的能力。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章