Java多线程系列—多线程带来的问题(05)

多线程带来的问题

为什么需要多线程

其实说白了,时代变了,现在的机器都是多核的了,为了榨干机器最后的性能我们引入单线程。

为了充分利用CPU资源,为了提高CPU的使用率,采用多线程的方式去同时完成几件事情而不互相干扰,为了处理大量的IO操作时或处理的情况需要花费大量的时间等等,比如:读写文件,视频图像的采集,处理,显示,保存等。

性能问题

上下文切换

Java 中的线程与 CPU 单核执行是一对一的,即单个处理器同一时间只能处理一个线程的执行;而 CPU 是通过时间片算法来执行任务的,不同的线程活跃状态不同,CPU 会在多个线程间切换执行,在切换时会保存上一个任务的状态,以便下次切换回这个任务时可以再加载到这个任务的状态,这种任务的保存到加载就是一次上下文切换。线程数越多,带来的上下文切换越严重,上下文切换会带来 CPU 系统态使用率占用,这就是为什么当我们开启大量线程,系统反而更慢的原因

其实你从这个表述中看到,其实整个切换的过程是有线程停止运行的,假设有这样一个工作有10个相同的步骤,每个线程处处理每一个步骤用的时间都是一样的,而且我们同时只能让一个线程工作,那这个时候多个线程之间的协调,也就是这里的调度就会占用很多时间,在公共量相等的情况下,我们的单线程肯定是比多线程要快的,但是现在我们的服务器都是多核,所以说多线程可以加快我们的处理速度,但是这是由前提的,就是线程数和我们的cpu 的核数的关系。

我们要减少上下文切换,有几种手段:

  • 减少锁等待:锁等待意味着,线程频繁在活跃与等待状态之间切换,增加上下文切换,锁等待是由对同一份资源竞争激烈引起的,在一些场景我们可以用一些手段减轻锁竞争,比如数据分片或者数据快照等方式。
  • CAS 算法:利用 Compare and Swap, 即比较再交换可以避免加锁。后续章节会介绍 CAS 算法。
  • 使用合适的线程数或者协程:使用合适的线程数而不是越多越好,在 CPU 密集的系统中,比如我们倾向于启动最多 2 倍处理器核心数量的线程;协程由于天然在单线程实现多任务的调度,所以协程实际上避免了上下文切换。

缓存失效

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

这里的缓存指的是CPU 缓存,关于cup 缓存大致如下,有多级缓存,和主存,所谓的主存就是我们的内存

Java多线程系列—多线程带来的问题(05)_第1张图片

  • L1 缓存很小但很快,并且紧靠着在使用它的 CPU 内核。
  • L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用
  • L3 在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享
  • 主存保存着程序运行的所有数据,它更大,更慢,由全部插槽上的所有 CPU 核共享
  • 当 CPU 执行运算的时候,它先去 L1 查找所需的数据,再去 L2,然后是 L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿

下面我们通过一段代码来演示一下缓存失效,我们知道Cache line 的大小一般是64 个字节,如果所以每次读取的时候如果在cpu 缓存里面有数据的话则将Cache line这一行全部读取,而不是读取Cache line里的某一个数据,也就是说Cache line 是我们的基本单位

public class CacheLineEffect {
    //考虑一般缓存行大小是64字节,一个 long 类型占8字节
    static long[][] arr;

    public static void main(String[] args) {
        // 创建一个数组
        arr = new long[1024 * 1024][8];
        for (int i = 0; i < 1024 * 1024; i++) {
            for (int j = 0; j < 8; j++) {
                arr[i][j] = 1L;
            }
        }
        //第一次累加 读取数组的全部数据进行累加
        long sum = 0L;
        long marked = System.currentTimeMillis();
        for (int i = 0; i < 1024 * 1024; i += 1) {
            for (int j = 0; j < 8; j++) {
                sum += arr[i][j];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms sum result: " + sum);
        sum = 0L;
        //第二次累加 读取数组的全部数据进行累加
        marked = System.currentTimeMillis();
        for (int i = 0; i < 8; i += 1) {
            for (int j = 0; j < 1024 * 1024; j++) {
                sum += arr[j][i];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms sum result: " + sum);
    }
}

这个代码的的特殊之处就是在遍历数组的方式不一样,第一次累加采用的是按行读取,第二次累加采用的是按列读取,而我们的第一次累加因为数组的大小正好是64个字节可以很好的利用cpu 缓存,也就是说一次从主存读取,然后后面7次就可以从cpu 缓存读取了也就是说总共需要读取主存1024 * 1024 次,但是第二次因为没法使用缓存,所以需要读取 1024 * 1024 * 8 次,下面就是输出结果

Loop times:12ms sum result: 8388608
Loop times:40ms sum result: 8388608

我们看到这之间的差异,还是比较大的,这里我们看到了CPU 缓存的重要性,同理多线程之间的切换也会导致CPU 缓存失效。

协作开销

线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。

还有就是你在自己的代码实现中,为了线程安全添加了相应的逻辑,从而打来了相应的开销。

什么时候要考虑线程安全问题

访问共享变量或资源

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

依赖时序的操作

第二个需要我们注意的场景是依赖时序的操作,如果我们操作的正确性是依赖时序的,而在多线程的情况下又不能保障执行的顺序和我们预想的一致,这个时候就会发生线程安全问题,如下面的代码所示:

if (map.containsKey(key)) {

    map.remove(obj)

}

代码中首先检查 map 中有没有 key 对应的元素,如果有则继续执行 remove 操作。此时,这个组合操作就是危险的,因为它是先检查后操作,而执行过程中可能会被打断。如果此时有两个线程同时进入 if() 语句,然后它们都检查到存在 key 对应的元素,于是都希望执行下面的 remove 操作,随后一个线程率先把 obj 给删除了,而另外一个线程它刚已经检查过存在 key 对应的元素,if 条件成立,所以它也会继续执行删除 obj 的操作,但实际上,集合中的 obj 已经被前面的线程删除了,这种情况下就可能导致线程安全问题。

类似的情况还有很多,比如我们先检查 x=1,如果 x=1 就修改 x 的值,代码如下所示:

if (x == 1) {
    x = 7 * x;
}

这样类似的场景都是同样的道理,“检查与执行”并非原子性操作,在中间可能被打断,而检查之后的结果也可能在执行时已经过期、无效,换句话说,获得正确结果取决于幸运的时序。这种情况下,我们就需要对它进行加锁等保护措施来保障操作的原子性。

对方没有声明自己是线程安全的

值得注意的场景是在我们使用其他类时,如果对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题。举个例子,比如说我们定义了 ArrayList,它本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在 ArrayList,因为它本身并不是并发安全的,正如源码注释所写的:

Note that this implementation is not synchronized. If multiple threads
access an ArrayList instance concurrently, and at least one of the threads
modifies the list structurally, it must be synchronized externally.

这段话的意思是说,如果我们把 ArrayList 用在了多线程的场景,需要在外部手动用 synchronized 等方式保证并发安全。

所以 ArrayList 默认不适合并发读写,是我们错误地使用了它,导致了线程安全问题。所以,我们在使用其他类时如果会涉及并发场景,那么一定要首先确认清楚,对方是否支持并发操作,以上就是四种需要我们额外注意线程安全问题的场景,分别是访问共享变量或资源,依赖时序的操作,不同数据之间存在绑定关系,以及对方没有声明自己是线程安全的。

总结

当你考虑多线程的时候就要考虑线程安全问题,那怎么发现那些地方会有线程安全问题呢——有共享变量的地方就有线程安全问题

我们认为引入多线程会带来两方面的问题

  1. 线程安全问题

  2. 性能问题

你可能感兴趣的:(java,多线程,多线程,java,面试)