什么是线程安全

214 阅读4分钟

一、什么线程安全?

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获取正确的结果,那这个对象便是线程安全的。

如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或者读写不能并行的问题,也不需要考虑任何额外的同步问题。

1.1、三种典型的线程安全问题
  1. 运行结果错误

    package com.strivelearn.concurrent.chapter03;
    ​
    import lombok.SneakyThrows;
    ​
    /**
     * @author strivelearn
     * @version ThreadSafeMain.java, 2022年12月24日
     */
    public class ThreadSafeMain {
    ​
        private static int count = 0;
    ​
        @SneakyThrows
        public static void main(String[] args) {
            Runnable calc = () -> {
                for (int i = 0; i < 10000; i++) {
                    count++;
                }
            };
            Thread t1 = new Thread(calc);
            t1.start();
            Thread t2 = new Thread(calc);
            t2.start();
            t1.join();
            t2.join();
            System.out.println(count);
        }
    }
    

    这段代码就是线程安全问题,引起来的运行结果不对。count++操作,从代码角度来看是一行代码,但实际上并不是一个原子操作,它的执行步骤主要分为散步,而且每步操作之间都可能被打断

    1. 读取count初始值
    2. 对count进行增加1
    3. 对count的结果进行保存
  2. 发布和初始化导致线程安全问题

    package com.strivelearn.concurrent.chapter03;
    ​
    import java.util.HashMap;
    import java.util.Map;
    ​
    /**
     * @author strivelearn
     * @version WrongInit.java, 2022年12月24日
     */
    public class WrongInitMain {
        private Map<Integer, String> studentList;
    ​
        // 在构造函数初始化的时候,启动一个新的线程进行对 studentList 进行赋值操作
        public WrongInitMain() {
            new Thread(() -> {
                studentList = new HashMap<>();
                for (int i = 0; i < 10; i++) {
                    studentList.put(i, "Student-" + i);
                }
            }).start();
        }
    ​
        public static void main(String[] args) {
            WrongInitMain wrongInitMain = new WrongInitMain();
            System.out.println(wrongInitMain.studentList.get(0));
        }
    }
    
    Exception in thread "main" java.lang.NullPointerException
      at com.strivelearn.concurrent.chapter03.WrongInitMain.main(WrongInitMain.java:25)
    

    运行此时会报NPE问题,因为在main方法里面,初始化WrongInitMain没有等新的线程对studentList初始化完毕,就是要了studentList的信息了。

  3. 活跃性问题

    3种典型的活跃性问题:

    • 死锁

      两个线程互相等待对方抢到的锁,从而导致死锁

    • 活锁

      正在运行的线程并没有被阻塞,它始终处于运行中,却一直得不到结果。比如一个消息队列,队列里面放着各种需要被处理的消息,而某个消息本身是错误的,执行时的报错,可队列重试机制把它放在队列头进行优先重试处理,但是这个消息无论被执行多少次,都始终无法被正确处理,这样每次周而复始,从而产生活锁的问题

    • 饥饿

      线程需要某些资源时始终得不到,尤其是CPU资源,就会导致线程一直不能运行而产生的问题

在Java中,Java的优先级分为1到10,1是最低的,10是最高的,如果我们把某个线程的优先级设置为1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到CPU资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。

二、哪些场景需要额外注意线程安全?

2.1、访问共享变量或者共享资源时

典型的场景有访问共享对象的属性,访问static静态变量,访问共享缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题

package com.strivelearn.concurrent.threadsafe;
​
/**
 * 访问共享变量或者共享资源
 * @author strivelearn
 * @version SharedVariable.java
 */
public class SharedVariable {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
​
        Runnable r = () -> {
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        };
​
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

上面的代码,运行不同的次数,返回的结果都小于20000

2.2、依赖时序的操作(主要是多个步骤而非原子操作)
if(map.containsKey(key)){
    map.remove(value);
}
  1. 先检查map中有没有key对应的元素
  2. 如果有则继续执行remove操作,这个组合操作是很危险的,因为它是先检查后操作,而非原子操作。
  3. 此时2个线程同时进入if语句,都满足存在key对应的元素,于是都执行了remove操作,此时代码就存在npe问题
2.3、不同数据之间存在相互绑定关系的情况

比如不同数据之间是成组出现的,存在着相互对应或者绑定的关系,最典型的就是IP和端口号。比如在更换了ip,往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就可能出现错误的ip与端口的绑定关系。

2.4、在我们使用其他类时,对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题

比如定义了ArrayList,因为该类本身并不是线程安全的,如果此时多个线程同时对ArrayList进行并发读写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在于ArrayList,因为它本身并不保证线程安全

image-20230110224618787

三、为什么多线程会带来性能问题?

3.1、什么是性能问题?

让多个线程同时工作,加快程序运行速度,为什么反而会带来性能问题呢?

因为单线程程序是独立运行的,不需要与其他线程进行交互。但是多线程之间则需要调度以及合作,调度与合作就会带来性能开销从而产生性能问题

3.2、性能问题的表现形式
  1. 服务器的响应慢
  2. 吞吐量低
  3. 内存占用过多
  4. ...
研究表明:

页面每多响应1秒,就会流失至少7%的用户,而超过8s无法返回结果的话,几乎所有用户都不会选择继续等待

3.3、什么情况下多线程编程会带来线程性能问题?
  1. 线程调度

    • 上下文切换

      在实际开发中,线程数往往是大于CPU核心数的。比如CPU核心数可能是8核,16核。。。,但是一个应用的线程数可能达到成百上千,这种情况下,操作系统就按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行

    • 缓存失效:

      不仅上下文切换会来性能问题,缓存失效也有可能带来性能问题

      由于程序由很大的概率会再次访问刚才的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快的获取数据,可一旦进行了线程调度,切换到其他的线程,CPU就会去执行不同的代码,原有的缓存就很有可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁的发生上下文切换,通常会给被调度的线程设置最小的执行,也就是只有执行完这段时间,才可能进行下一次调度,由此减少上下文切换的次数

    • 什么情况下会导致密集的上下文切换?

      如果程序频繁的竞争锁,或者由于IO读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生

  2. 线程协作

    • 线程之间共享数据

      因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和CPU对其进行重排序等优化,也可能处于同步的目的,反复把线程工作内存的数据flush到主存中,然后再从主内存refresh到其他线程的工作内存中。等情况

这些问题在单线程中并不存在,但是在多线程中为了保证数据的正确性,就不得不采取上述方法因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能