Java并发08:追寻并发的崇高理想-线程安全

90 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第8天,点击查看活动详情

学习MOOC视频记录的笔记

多线程是把双刃剑:可能导致安全、性能问题

一共有哪几类线程安全问题?

哪些场景需要额外注意线程安全问题?

什么是多线程带来的上下文切换?

1.线程安全

1.1 什么是线程安全?

《Java Concurrency In Practice》的作者Brian Goetz对“线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

简单定义:

不管业务中遇到怎样的多个线程访问某对象或某方法的情况,而在编程这个业务逻辑的时候,都不需要额外做任何额外的处理(也就是可以像单线程编程一样),程序也可以正常运行(不会因为多线程而出错),就可以称为线程安全。

线程不安全:get 同时 set、额外同步

全都线程安全?:运行速度、设计成本、trade off

完全不用于多线程:不过度设计

主要是两个问题:

  1. 数据争用:数据读写由于同时写,会造成错误数据。
  2. 竞争条件:即使不是同时写造成的错误数据,由于顺序原因依然会造成错误,例如在写入前就读取了。

1.2 什么情况下会出现线程安全问题,怎么避免?

  1. 运行结果错误(a++多线程下出现消失的请求现象,属于read-modify-write)

    1. 原子性
    2. 找到a++出错的地方
  2. 死锁等活跃性问题(包括死锁、活锁、饥饿)

  3. 对象发布和初始化的时候的安全问题

    什么是发布

    • 声明为public
    • return一个对象
    • 把对作为参数传递到其他类的方法中

    什么是逸出

    • 方法返回一个private.对象(private的本意是不让外部访问)

    • 还未完成初始化(构造函数没完全执行完毕)就把对象提供给外界

      • 在构造函数中未初始化完毕就this赋值
      • 隐式逸出一注册监听事件
      • 构造函数中运行线程

    如何解决逸出

    • 副本
    • 工厂模式
  4. 总结归纳:各种需要考虑线程安全的情况

    • 访问共享的变量或资源,会有并发风险,比如对象的属性、静态变量、共享缓存、数据库等
    • 所有依赖时序的操作,即使每一步操作都是线程安全的,还是存在并发问题:
      • read-modify-write操作:一个线程读取了一个共享数据,并在此基础上更新该数据。该例子在上面的index-++已经展示过了。
      • check-then-act操作:一个线程读取了一个共享数据,并在此基础上决定其下一个的操作
    • 不同的数据之间存在捆绑关系的时候
      • IP和端口号
    • 我们使用其他类的时候,如果对方没有声明自己是线程安全的,那么大概率会存在并发问题
      • 比如 HashMap 没有声明自己是并发安全的,所以我们并发调用 HashMap 的时候会出错

运行结果错误:a++多线程下出现消失的请求现象

/**
* 第一种:运行结果出错
* 演示计数不准确(减少),找出具体出错的位置。
*/
public class MultiThreadsError implements Runnable {
 
    int index = 0;
    static MultiThreadsError instance = new MultiThreadsError();
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("表面上结果是: " + instance.index);
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            index++;
        }
    }
}

运行结果:

表面上结果是: 12121

image-20221112145837711

i++看似是一条语句,其实在汇编的层面上涉及到三个步骤:取值,加1,写回;这里线程2读到了线程1还没有写回的数据,最终两次+1其实只执行了一次。

public class MultiThreadsError implements Runnable {
 
    int index = 0;
    static MultiThreadsError instance = new MultiThreadsError();
    static AtomicInteger realIndex = new AtomicInteger();
    static AtomicInteger wrongCount = new AtomicInteger();
    final boolean[] marked = new boolean[10000000];
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("表面上结果是: " + instance.index);
        System.out.println("真正运行的次数: " + realIndex.get());
        System.out.println("错误次数: " + wrongCount.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            index++;
            realIndex.incrementAndGet();
            if (marked[index]) {
                System.out.println("发生错误" + index);
                wrongCount.incrementAndGet();
            }
            marked[index] = true;
        }
    }
}

运行结果:

表面上结果是: 19947
真正运行的次数: 20000
错误次数: 61
public class MultiThreadsError implements Runnable {
 
    int index = 0;
    static MultiThreadsError instance = new MultiThreadsError();
    static AtomicInteger realIndex = new AtomicInteger();
    static AtomicInteger wrongCount = new AtomicInteger();
    final boolean[] marked = new boolean[10000000];
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("表面上结果是: " + instance.index);
        System.out.println("真正运行的次数: " + realIndex.get());
        System.out.println("错误次数: " + wrongCount.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            index++;
            realIndex.incrementAndGet();
            synchronized (instance) {
                if (marked[index]) {
                    System.out.println("发生错误" + index);
                    wrongCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
}

运行结果:

表面上结果是: 19999
真正运行的次数: 20000
错误次数: 745

结果说明:

  • marked 数组的操作不加同步的话,线程1先到 if (marked[index]) 语句处,发现这个位置之前没有被别人写过,跳过判断内部的语句,在使用 marked[index] = true 标记已经被写过之前,线程2先进行 if (marked[index]) 的判断,这个时候由于线程1还未标记,因此线程2也不会发现错误。
  • marked 数组的操作加了 synchronized 之后还是有问题。两个线程执行完前面两个代码,到 synchronized(instance)处拿锁,线程1拿到锁,并且此时 index = 1,线程1将 marked 数组的第一位置为 1,第二个线程进来之后,CPU切换回去,继续让线程1执行,线程1执行 index++index 更新了,此时 index 的值变成了2,线程2继续执行判断的是 marked[index] 即2位置上是否有冲突。
public class MultiThreadsError implements Runnable {
 
    int index = 0;
    static MultiThreadsError instance = new MultiThreadsError();
    static AtomicInteger realIndex = new AtomicInteger();
    static AtomicInteger wrongCount = new AtomicInteger();
    static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
    static volatile CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);
    final boolean[] marked = new boolean[10000000];
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("表面上结果是: " + instance.index);
        System.out.println("真正运行的次数: " + realIndex.get());
        System.out.println("错误次数: " + wrongCount.get());
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            // 需要两个线程都执行完await之后才继续往下执行
            try {
                cyclicBarrier2.reset();
                cyclicBarrier1.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            index++;
            // 等待两个线程都将 index+1 之后再判断
            try {
                cyclicBarrier1.reset();
                cyclicBarrier2.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            realIndex.incrementAndGet();
            synchronized (instance) {
                if (marked[index]) {
                    System.out.println("发生错误" + index);
                    wrongCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
}

运行结果:

表面上结果是: 19999
真正运行的次数: 20000
错误次数: 10000

可见还是有问题。

public class MultiThreadsError implements Runnable {
 
    int index = 0;
    static MultiThreadsError instance = new MultiThreadsError();
    static AtomicInteger realIndex = new AtomicInteger();
    static AtomicInteger wrongCount = new AtomicInteger();
    static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
    static volatile CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);
    final boolean[] marked = new boolean[10000000];
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("表面上结果是: " + instance.index);
        System.out.println("真正运行的次数: " + realIndex.get());
        System.out.println("错误次数: " + wrongCount.get());
    }
 
    @Override
    public void run() {
        marked[0] = true;
        for (int i = 0; i < 100000; i++) {
            // 需要两个线程都执行完await之后才继续往下执行
            try {
                cyclicBarrier2.reset();
                cyclicBarrier1.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            index++;
            // 等待两个线程都将 index+1 之后再判断
            try {
                cyclicBarrier1.reset();
                cyclicBarrier2.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
            realIndex.incrementAndGet();
            synchronized (instance) {
                // 两个线程在这里看到的index是一样的,都是偶数
                if (marked[index] && marked[index - 1]) {
                    System.out.println("发生错误" + index);
                    wrongCount.incrementAndGet();
                }
                marked[index] = true;
            }
        }
    }
}

结果:

发生错误141343
表面上结果是: 199999
真正运行的次数: 200000
错误次数: 1

出现 false, true, false, true, … 这种现象的原因是大部分情况下是两个线程分别执行了 index++ 之后 index 的值变加了2,这种属于正常的情况,因此需要修改判断的条件。

活跃性问题:死锁、活锁、饥饿

public class MultiThreadError implements Runnable {
 
    int flag = 1;
    static Object o1 = new Object();
    static Object o2 = new Object();
 
    public static void main(String[] args) {
        MultiThreadError r1 = new MultiThreadError();
        MultiThreadError r2 = new MultiThreadError();
        r1.flag = 1;
        r2.flag = 0;
        new Thread(r1).start();
        new Thread(r2).start();
    }
 
    @Override
    public void run() {
        System.out.println("flag = " + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("1");
                }
            }
        }
        if (flag == 0) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("1");
                }
            }
        }
    }
}

对象发布初始化的时候的安全问题

什么是发布?

让这个对象让超过类范围之内的其他位置使用。public修饰的对象;方法的return是一个对象,那么调用这个方法的类都获得了这个对象;将这个类作为参数。

什么是逸出?

也即发布到了不该发布的地方,分为以下几种情况:

  • 方法返回一个 private 对象(private的本意是不让外部访问)
  • 还未完成初始化(构造函数没完全执行完毕)就把对象提供给外界,比如:
  • 在构造函数中未初始化完毕就this赋值
  • 隐式逸出—注册监听事件
  • 构造函数中运行线程
public class MultiThreadsError3 {
    private Map<String, String> states;
 
    public MultiThreadsError3() {
        states = new HashMap<>();
        states.put("1", "周一");
        states.put("2", "周二");
        states.put("3", "周三");
        states.put("4", "周四");
    }
 
    public Map<String, String> getStates() {
        return states;
    }
 
    public static void main(String[] args) {
        MultiThreadsError3 multiThreadsError3 = new MultiThreadsError3();
        Map<String, String> states = multiThreadsError3.getStates();
        System.out.println(states.get("1"));
        states.remove("1");
        System.out.println(states.get("1"));
    }
}

states 被发送出去了,在外面被修改了,导致无法正常获取 key=1 对应的值。

如何解决:

  • 返回副本
  • 工厂模式

各种需要考虑线程安全的情况

  • 访问共享的变量或资源,会有并发风险,比如对象的属性、静态变量、共享缓存、数据库等
  • 所有依赖时序的操作,即使每一步操作都是线程安全的,还是存在并发 问题:read-modify-write、check-then-act
  • 不同的数据之间存在捆绑关系的时候
  • 我们使用其他类的时候,如果对方没有声明自己是线程安全的

2.性能问题有哪些体现、什么是性能问题

  • 服务响应慢、吞吐量低、资源消耗(例如内存)过高等
  • 虽然不是结果错误,但依然危害巨大
  • 引入多线程不能本末倒置

3.为什么多线程会带来性能问题

  1. 调度:上下文切换

    • 什么是上下文?保存现场 【context,当可运行的线程数量超过CPU的数量,那么操作系统就要调度线程,以便让每个线程都有机会运行;一次上下文切换会消耗5000~10000个CPU的时钟周期,大约几微秒;与寄存器相关】

      上下文切换可以认为是内核(操作系统的核心)在CPU上对于进程(包括线程)进行以下的活动:(1)挂起一个进程,将这个进程在CPU中的状态(上下文)存储于内存中的某处,(2)在内存中检索下一个进程的上下文并将其在CPU的寄存器中恢复,(3)跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。

    • 缓存开销:缓存失效 【程序有很大概率会访问之前访问过的数据,CPU为了加快执行的速度,会将不同的数据缓存在CPU里面,这样下次再次使用的时候很快就可以使用到了,但是一旦进行了上下文切换,CPU即将执行不同线程的不同代码,原来的缓存就失去了价值,CPU就需要重新进行缓存,这导致线程在被调度之后一开始的启动速度很慢,这是因为之前的缓存都失效了】

      CPU重新缓存

    • 何时会导致密集的上下文切换:抢锁、IO 【频繁的线程阻塞】

      频繁地竞争锁,或者由于IO读写等原因导致频繁阻塞

  2. 协作:内存同步

    • 编译器和CPU会帮助我们将程序进行优化,指令重排序,锁优化,让缓存失效了,无法使用自己的内存只能使用主存

    Java内存模型

    为了数据的正确性,同步手段往往会使用禁止编译器优化、使CPU内的缓存失效

4.常见面试问题

  1. 你知道有哪些线程不安全的情况?
    1. 运行结果错误(a++多线程下出现消失的请求现象,属于read-modify-write)
    2. 死锁等活跃性问题(包括死锁、活锁、饥饿)
    3. 对象发布和初始化的时候的安全问题
  2. 平时哪些情况下需要额外注意线程安全问题?
    1. 访问共享的变量或资源,会有并发风险,比如对象的属性、静态变量、共享缓存、数据库等
    2. 所有依赖时序的操作,即使每一步操作都是线程安全的,还是存在并发问题
    3. 不同的数据之间存在捆绑关系的时候
    4. 我们使用其他类的时候,如果对方没有声明自己是线程安全的,那么大概率会存在并发问题
  3. 什么是多线程的上下文切换?