Java解决线程安全问题

文章目录

    • 背景
    • 1. 线程安全问题
      • 1.1 什么是线程安全?
      • 1.2 产生的原因
      • 1.3 实例(买票超卖问题)
      • 1.4 如何确定是否存在线程安全问题?
    • 2. 如何解决线程安全问题?
      • 2.1 不可变(Immutable)
      • 2.2 变量私有化
        • 2.2.1 栈封闭(主要为局部变量)
        • 2.2.2 线程本地存储(Thread Local Storage)
      • 2.3 互斥同步
      • 2.4 非阻塞同步
        • 2.4.1 CAS
        • 2.4.2 Atomic(原子操作)
    • 3. 总结和分析
    • 参考

背景

原文地址:https://duktig.cn/archives/36/

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();

    }
}

结果

出现了共享数据错误

窗口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. 问题:三条线程同时共享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;
}

这里的变量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();
    }
}

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

Java解决线程安全问题_第1张图片

ThreadLocal实例内存结构

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

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

当调用一个 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);
}

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();
}

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();
    }
}

假定一个班级的初始分数是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解决线程安全问题_第2张图片

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

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

3. 总结和分析

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

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

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

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

参考

  • CS-Notes——Java并发
  • 如果你这样回答“什么是线程安全”,面试官都会对你刮目相看
  • Java面向对象进阶篇(包装类,不可变类)

你可能感兴趣的:(Java基础,Java,多线程,高并发,线程安全)