多线程--深入探究多线程的重点,难点以及常考点线程安全问题

简介: 多线程--深入探究多线程的重点,难点以及常考点线程安全问题

一.线程安全问题

1.为什么会有线程安全问题

线程安全问题在多线程编程中出现的根本原因是由于并发执行所带来的不确定性以及现代计算机系统在执行多线程任务时的内在机制。以下是线程安全问题产生的主要原因:

  1. 抢占式执行
  • 在多线程环境下,操作系统采用抢占式调度策略,这意味着线程可以在任何时候被停止执行或恢复执行,而不保证按照特定顺序完成。因此,线程间的执行顺序具有不确定性,可能导致数据竞争。
  1. 共享状态
  • 当多个线程访问并修改同一块共享数据时,如果没有适当的同步控制,就可能出现数据不一致或者竞态条件。例如,两个线程同时读取一个变量然后更新它,最终结果可能并不是每个线程单独操作所期望的结果。
  1. 非原子操作
  • 许多操作在硬件层面并不是原子的,即它们可以被中断并在稍后继续执行。如果一个非原子操作在执行过程中被另一个线程打断,可能导致数据损坏。
  1. 内存可见性
  • CPU和编译器为了性能优化,可能缓存数据到本地寄存器或缓存行中,而不是立即写回到主内存。这会导致不同线程看到的数据可能是过期的,即线程间对共享变量的修改彼此不可见。
  1. 指令重排序
  • 编译器或处理器为了优化性能,可能会重新安排指令执行的顺序,只要不影响单线程环境下的程序逻辑。但在多线程环境下,这种重排序可能导致依赖于特定执行顺序的代码出错。
  1. 死锁与资源争抢
  • 当多个线程相互等待对方释放资源时,可能会陷入永久阻塞的状态,即死锁。另外,如果资源分配不当,可能会导致某些线程长期得不到所需的资源而无法执行,形成饥饿现象。

综上所述,线程安全问题主要是由于并发执行中的数据访问冲突、操作的原子性和内存模型的复杂性等因素引起的。而出现这些问题,我们的代码就会有BUG(即不满足我们的业务要求就是BUG)

2.一个经典的线程安全的例子

1.Java代码

public class Demo {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        //保证线程1和线程2执行完毕
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

2.输出结果

1.第一次

2.第二次

3.说明

我们可以看到上面的代码,就是很简单的,先定义好一个静态的count变量,在线程1和线程2中各循环50000次,每次循环都加加,根据静态变量的特性,我们最后输出的count变量应该为100000,并且每次输出都一样才对,可是最后结果却每次的输出结果都不同,这就代表我们的代码出现了BUG.

4.原因说明

上述代码我们如果是在单线程的环境下,由于不存在并发访问共享资源的情况,当然是没问题的,而多线程,我们都知道,多线程在操作系统中,是抢占式调度,线程间的执行顺序具有不确定性,这便是我们每次执行代码输出结果都不同的主要原因,并且可能导致两个线程在未进行任何同步控制的情况下同时访问和修改 count 变量,进而产生竞态条件使得最终输出的 count 值低于预期的100000.

为了更通俗一点说明,博主通过画图的方式来帮助理解

5.画图说明

操作系统执行一个线程的过程,主要的就是CPU指令,我们就通过这底层的CPU指令来分析多线程问题

按照上图的CPU指令执行顺序,可以分为以下几步

1.t1从内存中的count,读取到寄存器中

2.t2从内存中的count,读取到寄存器中

3.t1寄存器中的count进行加加操作

4.t2寄存器中的count进行加加操作

5.把t1寄存器的值写入到内存中

6.把t2寄存器的值写入到内存中

通过上述过程我们可以发现,正是因为,线程是随机调度的原因导致我们的CPU指令执行的顺序也是随机,从而导致了原本应该加1的操作被重复计数,最终结果小于预期的100000存在线程安全问题.

注意:这只是在循环过程中CPU执行顺序的其中一种.所以我们每次启动代码得到的count值都不相同.

6.解决方法

在上文中我们也提到了为什么会发生线程安全问题

1.线程在操作系统执行时是随机调度的,抢占式执行的

2.多个线程同时访问一个共享数据

3.线程对数据的修改是非原子操作的

4.内存可见性问题

5.指令重排序

我们如何解决线程安全问题呢,当然就是从这些原因入手,首先第一个原因,虽然它是造成线程安全问题的主要原因,但这是操作系统这个层级的问题,我们也是"有心却无力",其次第二个原因是代码问题,我们当然是可以修改代码让多个线程不要同时访问一个共享数据,但大多时候,业务代码都是比较复杂的,你修改了一下代码,属于牵一发而动全身,反而得不偿失.第四,第五的原因,这里还不涉及到(下文在提及),而第三个原因,是解决线程安全问题的最朴实的方法,使用锁来将非原子的操作,封装成一个原子操作即使用锁.

二.锁

1.什么是锁

在多线程环境中,当多个线程同时尝试访问和修改同一份数据时,如果没有妥善的协调机制,将会引发竞态条件(Race Condition)、数据不一致等问题。锁就是用来解决这类问题的一种工具。在最简单的形式下,锁是一种二元状态标志,表示资源是否可用。当一个线程获得了锁,它可以访问受保护的资源;其他尝试获取同一把锁的线程则会被阻塞(挂起),直到该锁被释放为止。这样,锁就确保了在任何给定时间内,只有一个线程能够访问临界区(Critical Section)内的资源。

总的来说:锁主要的方式就是:1.加锁 2.解锁,它的主要特性就是有"互斥性"即,一个线程加锁了之后,直到该锁被持有线程释放(解锁)其他线程不可以尝试加锁了,另一个或者是多个就会,阻塞等待.正是有这个特性,使得锁可以用来解决线程安全问题.

2.如何加锁

在Java中我们主要是使用关键字"synchronized"来进行加锁

有以下几种加锁的例子

1.在非原子操作中加锁(内置锁)

public class Demo {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();//创建一个锁对象,无论是什么类型都可以为锁对象,
        //这里的锁对象只是做一个标识的作用
        Thread t1 = new Thread(()->{
            synchronized (locker) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        //保证线程1和线程2执行完毕
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

注意:这里的Object locker = new Object(); 创建一个锁对象,无论是什么类型都可以为锁对象,
这里的锁对象只是做一个标识的作用.最重要的是多个线程是否对同一个对象加锁这才是最重要的,如果是不同对象,那么锁就没有作用了.

2.使用synchronized修饰的方法

class Count {
    public static int count;
    Object locker = new Object();
    public static synchronized void add(){//使用synochronized修饰的方法
         count++;
    }
 
    public static int getCount() {
        return count;
    }
}
public class Demo10 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                Count.add();
            }
        });
        Thread t2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                Count.add();
            }
        });
        t1.start();
        t2.start();
        //保证线程1和线程2执行完毕
        t1.join();
        t2.join();
        System.out.println("count = " + Count.getCount());
    }
}

当然还有其他的方法使用synchronized来加锁这里就不过的赘述了

1.synchronized关键字

这里再解释一下synchronized关键字是Java语言中用于处理多线程同步的关键字

1.synchronize在使用于代码块时后面跟的()括号里面的参数为锁对象

2.synchronized(){}进入 { 时就是为()里的锁对象上锁,出入}才代表着解锁

3.为什么锁可以解决线程安全问题

就像上图表示的一样,只有当 t2 进行 lock操作时,锁已经被 t1 占有了,用于锁具有互斥性此时 t2 就只能阻塞等待,直到t1解锁后,t2才能执行 load add save CPU指令操作.这样就相当于count++这个操作是串行化执行的.这里需要注意的是博主说的这里是串行化执行的,仅仅是count++这个操作,两个线程还是并发执行的.

这里只是对锁的初步介绍,后续博主会更新更多关于锁的问题,大家感兴趣可以关注一下.

三.内存可见性问题

1.什么是内存可见性

内存可见性在多线程编程中是一个至关重要的概念,它涉及到当一个线程修改了共享变量的值后,其他线程能否及时看到这个修改后的值。在一个多核或多处理器系统中,每个线程可能有自己的工作内存,而主内存是所有线程共享的。

  • 问题背景: 当线程A在自己的工作内存中修改了共享变量的值,这个更改可能不会立刻同步回主内存,同时线程B也无法感知到线程A所做的更改,除非线程B也有某种机制来刷新或重新获取主内存中该变量的最新值。这就是所谓的内存不可见性问题。
  • 后果: 内存不可见性可能导致程序的行为变得不可预测,特别是在依赖于共享变量状态进行决策的并发代码中。如果不采取措施保证内存可见性,程序可能因为不同线程看到的变量值不同而产生各种错误,例如数据不一致、程序逻辑混乱等。

2.举一个例子

public class Demo11 {
    public static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (count == 0) {
                // do nothing
            }
            System.out.println("线程t1发现count值已改变,不再为0");
        });
 
        Thread t2 = new Thread(() -> {
            System.out.println("请输入count的值:");
            Scanner scan = new Scanner(System.in);
            count = scan.nextInt(); // 修改count的值
            scan.nextLine(); // 清除换行符
        });
 
        t1.start();
        t2.start();
 
        // 为了让t1有机会看到t2对count的修改,可以让主线程等待t2结束
        t2.join();
        System.out.println("主线程已确认t2线程结束,现在t1应能看到count的新值");
    }
}

预期的效果:用户在输入一个非0的整数t1就会退出循环并输出("线程t1发现count值已改变,不再为0") 我们可以看一下结果

我们可以发现t1并没有退出循环,这是为什么呢? 在多线程环境下,当一个线程修改了共享变量的值,其他线程并不一定能立即看到这个修改。这就是内存可见性问题

3.造成内存可见性问题主要原因

造成内存可见性问题,最主要的原因就是,jvm对操作进行了优化,使得t2修改了count值,但t1,无法察觉到(没有读取到),就造成内存可见性问题这里同样是从CPU指令这一层级进行分析

在由于循环体中是空的所以中主要执行以下两步操作

1.load : 将内存的count读取到CPU寄存器中

2.cmp : 比较,条件成立就继续顺序执行,条件不成立就跳转到另一个地址中(这里不过多解释)

由于指令的执行其实是很快的,所以,短时间内执行大量的重复的load 和 cmp .

1.由于load要从内存读取数据到寄存器中,这个操作比 cmp 操作要慢很多.

2.并且因为在t2修改前,count值其实是一样的.

3.基于上述原因这个时候 jvm就直接将load这个操作,优化成直接读取之前保存在寄存器中的值(这里只是描述了一下具体的优化是JVM要遵循JMM和编译器的优化规则),使代码的效率提高,这种做法固然是好的,但是在多线程的情况下,你直接读取寄存器的值,就读取不到count被t2修改后的值,导致发生线程安全问题,代码出现了BUG.

4.为什么在循环体里内不做任何事呢,就比如打印一句话,是因为,打印是需要进行I/O操作的,比load还要浪费时间,这个时候 jvm就不一定优化load过程了,虽然还是会产生内存可见性问题,但这是小概率问题了,不易于观察.

4.如何解决内存可见性问题

为了确保内存可见性,Java提供了以下几种机制:

  1. volatile关键字:声明一个变量为volatile可以禁止JVM和CPU对这个变量的读写操作进行重排序,并且要求每次读取该变量都从主内存获取,每次写入都同步到主内存,确保了多线程间的可见性。
  2. synchronized关键字:通过synchronized同步块或方法,不仅保证了在同一时刻只有一个线程访问临界区,而且还隐含地包含了内存可见性,即在同步块或方法结束时,会将修改过的共享变量刷回主内存,同时在进入同步块或方法前会从主内存重新加载变量的值。
  3. final关键字:对于final字段,JMM保证了在构造函数完成后,final字段的值对所有线程都是可见的。
  4. java.util.concurrent.atomic原子类:提供了一系列原子操作,这些操作保证了线程间的原子性和内存可见性。

由于 使用synchronized还会涉及到加锁,以及解锁的时间消耗,这里就不过多的介绍,这里最主要介绍的是volatile关键字.禁止JVM和CPU对这个变量的读写操作进行重排序,并且要求每次读取该变量都从主内存获取,这样就可以避免内存可见性问题了,

5.具体用法

public class Demo {
    public static volatile int count;//count被volatile修饰
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (count == 0) {
                // do nothing
            }
            System.out.println("线程t1发现count值已改变,不再为0");
        });
 
        Thread t2 = new Thread(() -> {
            System.out.println("请输入count的值:");
            Scanner scan = new Scanner(System.in);
            count = scan.nextInt(); // 修改count的值
            scan.nextLine(); // 清除换行符
        });
 
        t1.start();
        t2.start();
 
        // 为了让t1有机会看到t2对count的修改,可以让主线程等待t2结束
        t2.join();
        System.out.println("主线程已确认t2线程结束,现在t1应能看到count的新值");
    }
}

结果如下:

这个时候就可以避免内存可见性问题.

6.一道关于volatile关键字的面试问题

问题:volatile的作用,能否保证线程安全问题

volatile关键字在Java中主要作用于变量,其主要目的和作用包括:

  1. 可见性:当一个线程修改了标记为volatile的变量时,其他线程可以立即看到这个变量的最新值,而不是从各自的工作内存(缓存)中读取旧值。这是因为volatile变量的读写操作都会与主内存进行同步,每次读取都会从主内存获取,每次写入都会立即刷新到主内存。
  2. 禁止指令重排序:Java内存模型确保了对volatile变量的操作不会与其他普通变量的读写操作发生重排序,即在多线程环境下,对volatile变量的读写具有一定的顺序约束。

然而,volatile关键字不能完全保证线程安全。它不能防止多个线程同时读写同一变量时产生的竞态条件(race conditions),特别是对于需要多个连续操作组成的原子操作(如递增操作count++),volatile关键字无法保证其原子性。

举例来说,如果你有两个线程同时对一个volatile int count进行递增操作,尽管count的更新对所有线程是可见的,但由于递增操作不是原子的,所以仍然可能发生线程安全问题。

在实际应用中,要实现线程安全,对于需要多个线程读写共享数据的场景,单纯使用volatile往往是不够的,还需要结合synchronizedjava.util.concurrent包中的原子类(如AtomicInteger)或者其他同步机制来确保原子性和线程安全性。

以上就是关于线程安全的初步介绍,感谢你的阅读


相关文章
|
3天前
|
Java 数据库
【Java多线程】对线程池的理解并模拟实现线程池
【Java多线程】对线程池的理解并模拟实现线程池
16 1
|
1天前
|
Python
|
2天前
|
监控 Java 测试技术
在多线程开发中,线程死循环可能导致系统资源耗尽,影响应用性能和稳定性
【5月更文挑战第16天】在多线程开发中,线程死循环可能导致系统资源耗尽,影响应用性能和稳定性。为解决这一问题,建议通过日志记录、线程监控工具和堆栈跟踪来定位死循环;处理时,及时终止线程、清理资源并添加错误处理机制;编码阶段要避免无限循环,正确使用同步互斥,进行代码审查和测试,以降低风险。
17 3
|
3天前
|
设计模式 消息中间件 安全
【Java多线程】关于多线程的一些案例 —— 单例模式中的饿汉模式和懒汉模式以及阻塞队列
【Java多线程】关于多线程的一些案例 —— 单例模式中的饿汉模式和懒汉模式以及阻塞队列
11 0
|
3天前
|
Java
【Java多线程】分析线程加锁导致的死锁问题以及解决方案
【Java多线程】分析线程加锁导致的死锁问题以及解决方案
25 1
|
3天前
|
Java 数据库 Android开发
【专栏】Kotlin在Android开发中的多线程优化,包括线程池、协程的使用,任务分解、避免阻塞操作以及资源管理
【4月更文挑战第27天】本文探讨了Kotlin在Android开发中的多线程优化,包括线程池、协程的使用,任务分解、避免阻塞操作以及资源管理。通过案例分析展示了网络请求、图像处理和数据库操作的优化实践。同时,文章指出并发编程的挑战,如性能评估、调试及兼容性问题,并强调了多线程优化对提升应用性能的重要性。开发者应持续学习和探索新的优化策略,以适应移动应用市场的竞争需求。
|
3天前
|
存储 缓存 安全
【Java多线程】线程安全问题与解决方案
【Java多线程】线程安全问题与解决方案
21 1
|
3天前
|
Java 调度
【Java多线程】线程中几个常见的属性以及状态
【Java多线程】线程中几个常见的属性以及状态
13 0
|
3天前
|
Java 调度
【Java多线程】对进程与线程的理解
【Java多线程】对进程与线程的理解
13 1
|
3天前
|
存储 安全 Java
【探索Linux】P.21(多线程 | 线程同步 | 条件变量 | 线程安全)
【探索Linux】P.21(多线程 | 线程同步 | 条件变量 | 线程安全)
14 0