啃碎并发(五):Java线程安全的问题

究竟什么是线程安全?简单的说, 如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的
。那么,当进行多线程编程时,我们又会面临哪些线程安全的问题呢?又是要如何去解决的呢?

1 线程安全特性

1.1 原子性

跟数据库事务的原子性概念差不多, 即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)

关于原子性,一个非常经典的例子就是银行转账问题:

比如:A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。

1.2 可见性

可见性是指, 当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到
。可见性问题是好多人忽略或者理解错误的一点。

CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。 每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)
。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。

1.3 有序性

有序性指的是, 程序执行的顺序按照代码的先后顺序执行
。以下面这段代码为例:

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心, CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致

2 线程安全问题

2.1 竞态条件与临界区

线程之间共享堆空间,在编程的时候就要格外注意避免竞态条件。危险在于多个线程同时访问相同的资源并进行读写操作。 当其中一个线程需要根据某个变量的状态来相应执行某个操作的之前,该变量很可能已经被其它线程修改

也就是说,当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在 竞态条件
。导致竟态条件发生的代码称作 临界区

/**
 * 以下这段代码就存在竞态条件,其中return ++count就是临界区。
 */
public class Obj
{

    private int count;

    public int incr()
    {
        return ++count;
    }

}

2.2 死锁

死锁: 指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去
。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

关于死锁发生的条件:

  1. 互斥条件
    :线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到资源被释放。
  2. 请求和保持条件
    :线程T1至少已经保持了一个资源R1占用,但又提出对另一个资源R2请求,而此时,资源R2被其他线程T2占用,于是该线程T1也必须等待,但又对自己保持的资源R1不释放。
  3. 不剥夺条件
    :线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。
  4. 环路等待条件
    :在死锁发生时,必然存在一个“进程-资源环形链”,即: {p0,p1,p2,...pn}
    ,进程p0(或线程)等待p1占用的资源,p1等待p2占用的资源,pn等待p0占用的资源。 (最直观的理解是,p0等待p1占用的资源,而p1而在等待p0占用的资源,于是两个进程就相互等待)

2.3 活锁

活锁:是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。 这样你让我,我让你,最后两个线程都无法使用资源

关于“死锁与活锁”的比喻:

死锁:迎面开来的汽车A和汽车B过马路,汽车A得到了半条路的资源(满足死锁发生条件1:资源访问是排他性的,我占了路你就不能上来,除非你爬我头上去),汽车B占了汽车A的另外半条路的资源,A想过去必须请求另一半被B占用的道路(死锁发生条件2:必须整条车身的空间才能开过去,我已经占了一半,尼玛另一半的路被B占用了),B若想过去也必须等待A让路,A是辆兰博基尼,B是开奇瑞QQ的屌丝,A素质比较低开窗对B狂骂:快给老子让开,B很生气,你妈逼的,老子就不让(死锁发生条件3:在未使用完资源前,不能被其他线程剥夺),于是两者相互僵持一个都走不了(死锁发生条件4:环路等待条件),而且导致整条道上的后续车辆也走不了。

活锁:马路中间有条小桥,只能容纳一辆车经过,桥两头开来两辆车A和B,A比较礼貌,示意B先过,B也比较礼貌,示意A先过,结果两人一直谦让谁也过不去。

2.4 饥饿

饥饿:是指如果线程T1占用了资源R,线程T2又请求封锁R,于是T2等待。T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T4的请求……, T2可能永远等待

关于”饥饿“的比喻:

在“首堵”北京的某一天,天气阴沉,空气中充斥着雾霾和地沟油的味道,某个苦逼的临时工交警正在处理塞车,有两条道A和B上都堵满了车辆,其中A道堵的时间最长,B相对堵的时间较短,这时,前面道路已疏通,交警按照最佳分配原则,示意B道上车辆先过,B道路上过了一辆又一辆,A道上排队时间最长的却没法通过,只能等B道上没有车辆通过的时候再等交警发指令让A道依次通过, 这也就是ReentrantLock显示锁里提供的不公平锁机制
(当然了,ReentrantLock也提供了公平锁的机制,由用户根据具体的使用场景而决定到底使用哪种锁策略), 不公平锁能够提高吞吐量但不可避免的会造成某些线程的饥饿

3 如何确保线程安全特性

3.1 如何确保原子性

3.1.1 锁和同步

常用的保证Java操作原子性的工具是 锁和同步方法(或者同步代码块)
。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

public void testLock () {
    lock.lock();
    try{
        int j = i;
        i = j + 1;
    } finally {
        lock.unlock();
    }
}

与锁类似的是同步方法或者同步代码块。 使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象
。下面是同步代码块示例:

public void testLock () {
    synchronized (anyObject){
        int j = i;
        i = j + 1;
    }
}

无论使用锁还是synchronized,本质都是一样, 通过锁或同步来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行
,进而保证了目标代码段的原子性。 这是一种以牺牲性能为代价的方法

3.1.2 CAS(compare and swap)

基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作。Java中提供了对应的原子操作类来实现该操作,并保证原子性, 其本质是利用了CPU级别的CAS指令
。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger使用方法如下:

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
    new Thread(() -> {
        for(int a = 0; a < iteration; a++) {
            atomicInteger.incrementAndGet();
        }
    }).start();
}

3.2 如何确保可见性

Java提供了volatile关键字来保证可见性。 当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它线程缓存中对该变量的缓存设置成无效
,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。

volatile适用场景:volatile适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记。如下所示:

boolean isRunning = false;
public void start () {
    new Thread( () -> {
        while(isRunning) {
            someOperation();
        }
    }).start();
}
public void stop () {
    isRunning = false;
}

在这种实现方式下,即使其它线程通过调用stop()方法将isRunning设置为false,循环也不一定会立即结束。 可以通过volatile关键字,保证while循环及时得到isRunning最新的状态从而及时停止循环,结束线程

3.3 如何确保有序性

上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致, 所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性

Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。
synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。

除了从应用层面保证目标代码段执行的顺序性外, JVM还通过被称为happens-before原则隐式地保证顺序性
。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。

happens-before原则(先行发生原则),如下:

  1. 传递规则
    :如果操作1在操作2前面,而操作2在操作3前面,则操作1肯定会在操作3前发生。 该规则说明了happens-before原则具有传递性
  2. 锁定规则
    :一个unlock操作肯定会在后面对同一个锁的lock操作前发生。 这个很好理解,锁只有被释放了才会被再次获取
  3. volatile变量规则
    :对一个被volatile修饰的写操作先发生于后面对该变量的读操作。
  4. 程序次序规则
    :一个线程内,按照代码顺序执行。
  5. 线程启动规则
    :Thread对象的start()方法先发生于此线程的其它动作。
  6. 线程终结原则
    :线程的终止检测后发生于线程中其它的所有操作。
  7. 线程中断规则
    : 对线程interrupt()方法的调用先发生于对该中断异常的获取。
  8. 对象终结规则
    :一个对象构造先于它的finalize发生。

原文 

https://juejin.im/post/5b35af3151882574aa5f69c5

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 啃碎并发(五):Java线程安全的问题

分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址