Java解决线程安全问题

2020/1/1

# 1. 线程安全问题

# 1.1 什么是线程安全?

线程安全:多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。

非线程安全问题:指多个线程对同一个对象中的同一个实例变量进行操作时会出现值被更改、值不同步的情况,进而影响程序的执行流程。

“线程安全”不是指线程的安全,而是指内存的安全。为什么如此说呢?这和操作系统有关。

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。但是进程中的多个线程共享进程的堆内存,这就是造成问题的潜在原因。

假设某个线程把数据处理到一半,觉得很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是自己离开时的样子了。可能被其它线程修改了。

比如把你住的小区看作一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。因为公共区域人来人往,你放的东西在没有看管措施时,一定是不安全的。内存中的情况亦然如此。

所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。

即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。

# 1.2 产生的原因

  1. 多个线程执行的不确定性引起执行结果的不稳定。
  2. 多个线程对数据的共享,会造成操作的不完整性,会破坏数据。

当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行,导致共享数据的错误。

# 1.3 实例(买票超卖问题)

/**
 * 例子:创建三个窗口卖票,总票数为100张.使用继承Thread类的方式
 *
 * 存在线程的安全问题,待解决。
 */
class Window extends Thread{

    private static int ticket = 100;
    @Override
    public void run() {

        while(true){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if(ticket > 0){
                System.out.println(getName() + ":卖票,票号为:" + ticket);
                ticket--;
            }else{
                break;
            }
        }

    }
}

public class WindowTest {
    public static void main(String[] args) {
        Window t1 = new Window();
        Window t2 = new Window();
        Window t3 = new Window();

        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        t1.start();
        t2.start();
        t3.start();

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

结果

出现了共享数据错误

窗口2:卖票,票号为:100
窗口1:卖票,票号为:100
窗口3:卖票,票号为:98
窗口3:卖票,票号为:97
窗口2:卖票,票号为:97
窗口1:卖票,票号为:95
窗口1:卖票,票号为:94
窗口2:卖票,票号为:93
窗口3:卖票,票号为:92
窗口3:卖票,票号为:91
......
窗口1:卖票,票号为:10
窗口3:卖票,票号为:10
窗口2:卖票,票号为:10
窗口2:卖票,票号为:7
窗口3:卖票,票号为:7
窗口1:卖票,票号为:5
窗口3:卖票,票号为:4
窗口2:卖票,票号为:4
窗口1:卖票,票号为:2
窗口2:卖票,票号为:1
窗口3:卖票,票号为:1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

分析

  1. 问题:三条线程同时共享ticket的票,卖票过程中,出现了重票、错票 -->出现了线程的安全问题,导致数据错误。
  2. 原因:当某个线程操作车票的过程中,尚未操作完成时,其他线程参与进来,也操作车票。
  3. 解决:当一个线程a在操作ticket的时候,其他线程不能参与进来。直到线程a操作完ticket时,其他线程才可以开始操作ticket。这种情况即使线程a出现了阻塞,也不能被改变。

# 1.4 如何确定是否存在线程安全问题?

  • 明确哪些代码是多线程运行的代码
  • 明确多个线程是否有共享数据
  • 明确多线程运行代码中是否有多条语句操作共享数据

# 2. 如何解决线程安全问题?

如何解决线程安全问题?解决的过程其实就是一个取舍的过程,不同的方案有不同的侧重点。

# 2.1 不可变(Immutable)

不可变的对象一定是线程安全的,不需要采取任何的线程安全措施。只要一个不可变的对象被正确的构建出来,在多线程的状态下也不允许修改,那么一定不会发生不一致的状态。

形象比喻:只能看,不能摸。自然不会存在线程安全问题。

在多线程的环境下,应尽量是对象成为不可变的状态,来满足线程安全,这基本也是最小的开销方式来保证线程安全。

不可变类的意思是创建该类的实例后,该实例的实例变量是不可改变的。

不可变的类型:

  • String
  • 枚举类型
  • 基本数据类型的包装类以及BigIntegerBigDecimal 等大数据类型
  • final关键字修饰的基本数据类型

注:final修饰的引用数据类型不可变,但其成员变量的类可能会发生改变

# 2.2 变量私有化

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

# 2.2.1 栈封闭(主要为局部变量)

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的

形象比喻:私有的东西就不该让别人知道

如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是局部变量。

double avgScore(double[] scores) {
    double sum = 0;
    for (double score : scores) {
        sum += score;
    }
    int count = scores.length;
    double avg = sum / count;
    return avg;
}
1
2
3
4
5
6
7
8
9

这里的变量sumcountavg都是局部变量,它们都会被分配在线程栈内存中。

假如现在A线程来执行这个方法,这些变量会在A的栈内存分配。与此同时,B线程也来执行这个方法,这些变量也会在B的栈内存中分配。

也就是说这些局部变量会在每个线程的栈内存中都分配一份。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其它线程根本就不知道,也就不存在线程安全问题。

问题

不可能所有的变量都只声明成局部变量,而只供某个线程使用,去解决线程安全问题。如果想要声明成员变量,还想要保证线程安全怎么办?

可以使用ThreadLocal,使每个线程都私有化一份这个变量的本地副本,互相不受影响。

# 2.2.2 线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

可以使用java.lang.ThreadLocal类来实现线程本地存储功能。

通俗来说:要让公共区域堆内存中的数据对于每个线程都是安全的,那就每个线程都拷贝它一份,每个线程只处理自己的这一份拷贝而不去影响别的线程的,这不就安全了嘛。

形象比喻:大家不要抢,人人有份

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

为了理解 ThreadLocal,先看以下代码:

public class ThreadLocalExample1 {
    public static void main(String[] args) {
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

它所对应的底层结构图为:

ThreadLocal实例内存结构 (opens new window)

ThreadLocal实例内存结构 (opens new window)

每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象。

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
1
2
3

当调用一个 ThreadLocalset(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value 键值对插入到该 Map 中。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
1
2
3
4
5
6
7
8

get() 方法类似。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}
1
2
3
4
5
6
7
8
9
10
11
12
13

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。ThreadLocal就是,把一个数据复制N份,每个线程认领一份,各玩各的,互不影响。

在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险

# 2.3 互斥同步

前面给出的一些方案,有点“理想化”了,现实中的情况其实是非常混乱嘈杂的,没有规则的。

形象比喻:没有规则,那就先入为主

例子:

比如在中午高峰期你去饭店吃饭,进门后发现只剩一个空桌子了,你心想先去点餐吧,回来就坐这里吧。当你点完餐回来后,发现已经被别人捷足先登了。

因为桌子是属于公共区域的物品,任何人都可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。

解决方法就不用我说了吧,让一个人在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就可以理直气壮的说,“不好意思,这个座位,我,已经占了”。

相信聪明的你已经猜到了我要说的东西了,没错,就是互斥锁

如果公共区域(堆内存)的数据,要被多个线程操作时,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据,先获取锁再说吧。

假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。于是它就拿到了这把锁,然后开始操作数据。

这时,又来了一个线程,发现锁被别人持有着,按照规定,它不能操作数据,因为它无法得到这把锁。当然,它可以选择等待,或放弃,转而去干别的。

因为第一个线程持有锁,可以大胆干事而不用担心其他线程的影响。

对于互斥同步锁,可以使用synchronizedReentrantLock

class ClassAssistant {

    double totalScore = 60;
    final Lock lock = new Lock();

    void addScore(double score) {
        lock.obtain();
        totalScore += score;
        lock.release();
    }

    void subScore(double score) {
        lock.obtain();
        totalScore -= score;
        lock.release();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

假定一个班级的初始分数是60分,这个班级抽出10名学生来同时参加10个不同的答题节目,每个学生答对一次为班级加上5分,答错一次减去5分。因为10个学生一起进行,所以这一定是一个并发情形。

因此加分和减分这两个方法被并发的调用,它们共同操作总分数。为了保证数据的一致性,需要在每次操作前先获取锁,操作完成后再释放锁。

问题

互斥阻塞会有线程阻塞和唤醒所带来的性能问题。

# 2.4 非阻塞同步

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

形象比喻:相信世界充满爱,即使被伤害

由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁

乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

# 2.4.1 CAS

例子解释:

例子,假如你往地上仍1万块钱,是不是一定会丢呢?这要看情况了,如果是在人来人往的都市,可以说肯定会丢的。如果你跑到无人区扔地上,可以说肯定不会丢。

可以看到,都是把东西无保护的放到公共区域里,结果却相差很大。这说明安全问题还和公共区域的环境状况有关系。

比如我把数据放到公共区域的堆内存中,但是始终都只会有1个线程,也就是单线程模型,那这数据肯定是安全的。

再者说,2个线程操作同一个数据和200个线程操作同一个数据,这个数据的安全概率是完全不一样的。肯定线程越多数据不安全的概率越大,线程越少数据不安全的概率越小。取个极限情况,那就是只有1个线程,那不安全概率就是0,也就是安全的。

因为锁的获取和释放是要花费一定代价的,如果在线程数目特别少的时候,可能可能就不会有别的线程来操作数据,此时你还要获取锁和释放锁,可以说是一种浪费。

针对这种“地广人稀”的情况,专门提出了一种方法,叫CAS。就是在并发很小的情况下,数据被意外修改的概率很低,但是又存在这种可能性,此时就用CAS。

CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的变量(var)——内存地址
  • E:预期值(expected)——旧值
  • N:新值(new)

比较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。

我们以一个简单的例子来解释这个过程:

  1. 如果有一个多个线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6;
  2. 我们使用CAS来做这个事情;
  3. 首先我们用i去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i的值被设置成了6;
  4. 如果不等于5,说明i被其它线程改过了(比如现在i的值为2),那么我就什么也不做,此次CAS失败,i的值仍然为2。

在这个例子中,i就是V,5就是E,6就是N。

那有没有可能我在判断了i为5之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?

不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。

CAS是一种原子操作,在Java中,有一个Unsafe类,它在sun.misc包中。它里面是一些native方法,其中就有几个关于CAS的,他们都是public native的。

当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

ABA问题?(狸猫换太子)

因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了,那 CAS 操作就会误认为它从来没有被改变过。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。

Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

# 2.4.2 Atomic(原子操作)

Unsafe类支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢?

JDK提供了一些用于原子操作的类,在java.util.concurrent.atomic包下面。在JDK 8中,有如下17个类:

java.util.concurrent.atomic (opens new window)

从名字就可以看得出来这些类大概的用途:

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新字段(属性)

# 3. 总结和分析

“栈封闭”:找个只有自己知道的地方藏起来,当然安全了。

ThreadLocal:每人复制1份,各玩各的,互不影响,当然也安全了。

“不可变”:更狠了,直接规定,只能读取,禁止修改,当然也安全了。

互斥同步非阻塞同步,分别对应悲观锁乐观锁的策略。

# 参考