JAVA线程中的安全性问题详解

多线程很难掌握,稍不注意,就容易使程序崩溃。我们以在路上开车为例:

在一个单向行驶的道路上,每辆汽车都遵守交通规则,这时候整体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味着『多个 job 任务』。

单线程顺利同行

单线程顺利同行:

如果需要提升车辆的同行效率,一般的做法就是扩展车道,对应程序来说就是『加线程池』,增加线程数。这样在同一时间内,通行的车辆数远远大于单车道。

多线程顺利同行

多线程顺利同行:

然而车道一旦多起来,『加塞』的场景就会越来越多,出现碰撞后也会影响整条马路的通行效率。这么一对比下来『多车道』就比『单车道』慢多了。

多线程故障

多线程故障:

防止汽车频繁变道加塞可以在车道间增加『护栏』,那在程序的世界里该怎么做呢?

多线程遇到的问题归纳起来就三类:『线程安全问题』『活跃性问题』『性能问题』

 线程安全问题

原子性

举一个银行转账的例子,比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元,两个操作都成功才意味着一次转账最终成功。

 

试想一下,如果这两个操作不具备原子性,从 A 的账户扣减了 1000 元之后,操作突然终止了,账户 B 没有增加 1000 元,那问题就大了。

 

银行转账有两个步骤,出现意外后导致转账失败,说明没有原子性。

  • 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。

在并发编程中很多操作都不是原子操作,出个小题目:

int i = 0; // 操作1
i++;   // 操作2
int j = i; // 操作3
i = i + 1; // 操作4

上面这四个操作中哪些是原子操作,哪些不是呢?

有些小伙伴可能认为这些都是原子操作,其实只有操作 1 是原子操作。

  • 操作 1:这是原子操作,因为它是一个单一的、不可分割的步骤。
  • 操作 2:这不是原子操作。这实际上是一个 "read-modify-write" 操作,它包括了读取 i 的值,增加 i,然后写回 i。
  • 操作 3:这是原子操作,因为它是一个单一的、不可分割的步骤。
  • 操作 4:这不是原子操作。和 i++ 一样,这也是一个 "read-modify-write" 操作。

 在单线程环境下上述四个操作都不会出现问题,但是在多线程环境下,如果不加锁的话,可能会得到意料之外的值。我们来测试一下,看看输出结果。

public class YuanziDeo {
    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        int numThreads = 2;
        int numIncrementsPerThread = 100000;

        Thread[] threads = new Thread[numThreads];

        for (int j = 0; j < numThreads; j++) {
            threads[j] = new Thread(() -> {
                for (int k = 0; k < numIncrementsPerThread; k++) {
                    i++;
                }
            });
            threads[j].start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Final value of i = " + i);
        System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
    }
}

输出如下:

Final value of i = 102249
Expected value = 200000

i 期望的值为 200000,但实际跑出来的是 102249,这证明 i++ 不是一个原子操作,对吧?

原因解析:

数据竞争,i++操作不是原子,这意味着在i++执行的过程中,i可能会被其他的线程读取和修改。

在没有同步的情况下,线程A可能读取了i的值,接着两个线程都增加了1.并把结果写回i,这样实际上只进行了一次递增的操作。

可见性

假如有两个线程,线程 1 执行 update 方法将 i 赋值为 100,一般情况下线程 1 会在自己的工作内存中完成赋值操作,但不会及时将新值刷新到主内存中。

这个时候线程 2 执行 get 方法,首先会从主内存中读取 i 的值,然后加载到自己的工作内存中,此时读到 i 的值仍然是 50,再将 50 赋值给 j,最后返回 j 的值就是 50 了。原本期望返回 100,结果返回 50,这就是可见性问题,线程 1 对变量 i 进行了修改,线程 2 并没有立即看到 i 的新值。

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

 

 

如上图,每个线程都有属于自己的工作内存,工作内存和主内存间需要通过 store 和 load 等进行交互。

为了解决多线程的可见性问题,Java 提供了volatile这个关键字。当一个共享变量被 volatile 修饰时,它会保证修改的值立即更新到主存当中,这样的话,当有其他线程需要读取时,就会从内存中读到新值。普通的共享变量不能保证可见性,因为变量被修改后什么时候刷回到主存是不确定的,因此另外一个线程读到的可能就是旧值。

当然 Java 的锁机制如 synchronized 和 lock 也是可以保证可见性的。

活跃性问题

上面讲到为了解决可见性的问题,我们可以采取加锁的方式来解决,但如果加锁使用不当也容易引入其他问题,比如『死锁』。

在讲『死锁』之前,我们需要先引入另外一个概念:活跃性问题

活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。

概念可能有点拗口,活跃性问题一般有这样几类:死锁活锁饥饿问题

死锁

 死锁是计算机科学中一个重要的概念,特别是在操作系统和并发编程领域。当两个或多个进程(或线程)在执行过程中因争夺资源而造成的一种僵局状态时,就会发生死锁。在这种状态下,每个进程都在等待其他进程释放它所需要的资源,因此没有一个进程能够继续执行下去。

活锁

活锁(Livelock)是指在多线程或并发系统中的一种特殊情形,其中两个或多个进程不断地重复尝试执行某个操作,但每次都被另一个进程阻止,结果导致所有相关的进程都无法继续向前推进,尽管它们都在持续地尝试改变状态。

活锁与死锁不同,死锁中的进程完全停止了执行,而活锁中的进程则是在不断地尝试执行,但是由于相互之间的干扰,最终没有任何进程能够完成其任务。

 饥饿

如果一个线程无其他异常却迟迟不能继续运行,那基本上是处于饥饿状态了。

常见的有几种场景:

  • 高优先级的线程一直在运行消耗 CPU,所有的低优先级线程一直处于等待;
  • 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问;

性能问题

 1. 线程创建和销毁成本

 问题:创建和销毁线程需要消耗时间和资源,频繁地创建和销毁线程会降低性能。

 解决方案:

  1.    使用线程池来复用线程,减少线程创建和销毁的开销。
  2.    对于短暂的任务,考虑使用工作队列来分发任务给现有的线程。

 2. 上下文切换

 问题:当操作系统在多个线程之间切换执行时,需要保存当前线程的状态并加载新线程的状态,这会带来额外的开销。

 解决方案:

  1.    减少线程的数量,避免不必要的线程切换。
  2.    使用协作式调度,如用户态线程,减少内核态到用户态的切换。
  • 9
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值