多线程之线程安全问题

目录

1. 线程安全

1.1线程安全问题产生的原因

1.1.1 抢占式执行

 1.1.2 多个线程同时修改一个变量

1.1.3 修改操作不是原子性

1.1.4 内存可见性问题

1.1.5 指令重排序

1.2 加锁

1.2.1 synchronized 的使用方式

1.2.2 synchronized 的特性

 1.2.3 死锁

1.2.3.1 死锁的三种情况:

1.2.3.2 死锁形成的四个必要条件:

1.3 Java 标准库中的线程安全类

1.4 volatile 关键字

1.4.1 volatile 特性

1.4.2 内存可见性

1.4.2.1 从汇编的角度来看内存可见性问题

1.4.2.2 从 JMM 的角度来看内存可见性问题 

1.4.3 synchronized 和volatile 的区别


观前提示:本篇博客演示使用的 IDEA 版本为2021.3.3版本,使用的是Java8(又名jdk1.8)


1. 线程安全

在上一篇博客(多线程之多线程的的三种状态)中,我们在使用 wait 方法的时候,提到了线程安全问题,要解决线程安全问题需要使用 synchronized 方法,简单讲解了一下为什么会有线程安全,这篇博客将详细介绍线程安全问题

一个多线程程序实际的执行顺序有多种变数,在所有的变数下,安全运行,这就是线程安全


1.1线程安全问题产生的原因

可能威胁到线程安全的原因有很多,常见的有以下几种

1.抢占式执行,随机调度

2.多个线程同时修改一个变量

3.修改操作不是原子性

4.内存可见性问题

5.指令重排序


1.1.1 抢占式执行

抢占式执行是指操作系统通过对进程或线程进行优先级调度,并在高优先级进程或线程运行的时候强制暂时停止低优先级进程或线程,从而实现资源的高效利用和任务的快速响应。这种调度方式被广泛应用于多任务操作系统、实时系统或需要快速响应的计算机系统中。

抢占式执行强制要求系统保存当前进程或线程的执行状态,以便随时转换到下一个进程或线程,并保证系统的响应速度和稳定性。抢占式执行所需的硬件和软件支持已经广泛存在于现代计算机系统中,例如在多核处理器上实现线程的抢占式执行、在操作系统内核中实现进程抢占等。

需要注意的是,与非抢占式执行相比,抢占式执行虽然可以提高系统的性能和响应速度,但是由于经常进行上下文切换,可能增加系统的开销和延迟,因此需要权衡系统性能和稳定性的关系。


 1.1.2 多个线程同时修改一个变量

多个线程同时修改一个变量可能会导致竞态条件的出现。竞态条件是指多个线程或进程在同时运行时,由于执行顺序的不确定性而导致执行结果的不可预期。在多个线程同时修改同一个变量时,如果没有采取合适的同步措施,就会导致数据的不一致性和程序的错误。


1.1.3 修改操作不是原子性

当多个线程同时尝试修改同一个变量时,如果这些修改操作不是原子性的,就可能会导致竞态条件的出现。原子性指的是一个操作要么全部执行完毕,要么完全不执行,不会出现执行了一半的情况。例如,在多个线程同时修改同一个变量时,如果修改操作没有被设计为原子性操作,那么可能出现其中一个线程在执行修改操作期间被中断的情况,此时变量的值可能处于不一致的状态。

例如:

假设两个线程,每个线程各自自增五万次,总共是十万次,下图为理想状况

然鹅,实际上却是这样

具体的抢占方式 不只是上面这些,他可以在 load add save 里面随机抢占,造成最终累加的结果不是十万

具体可以看Java多线程之多线程的状态,里面的 synchronized 部分

如上篇所示使用: synchronized 方法可以有效解决上述问题


1.1.4 内存可见性问题

内存可见性问题指的是多个线程操作共享变量时,由于每个线程都有自己的 CPU 缓存,可能会导致对共享变量的修改不被其他线程及时发现,从而引发数据不一致的问题。

在单线程环境下,共享变量被存储在主内存中,CPU 直接读取和修改主内存中的值。但在多线程环境下,每个线程都有自己的 CPU 缓存,当线程读取共享变量时,首先会尝试读取自己 CPU 缓存中的值,如果缓存中没有该变量的值,才会从主内存中读取变量的值,并把这个值存储到本地缓存中。

如果一个线程对共享变量的值进行了修改,这个修改可能不会立即被写入到主内存中,而是会缓存在该线程的 CPU 缓存中,这时其他线程在本地缓存中读取到的依然是原来的值,从而出现数据不一致的问题。

为了解决内存可见性问题,可以使用 volatile、synchronized 以及锁等机制来保证多线程环境下对共享变量的可见性和原子性,并避免数据不一致的问题。

总的来说,内存可见性问题是多线程编程中常见的问题之一,需要开发人员在代码编写时注意避免,并使用相应的机制进行保证。


1.1.5 指令重排序

指令重排序是指编译器或处理器在不改变程序执行结果的前提下,为了优化程序性能而重新排列指令执行顺序的过程。在多核CPU和超标量技术的帮助下,指令重排序可以提高处理器整体的运算速度。同时,在单线程程序中也会有指令重排序的优化,常见的优化技术包括循环展开、函数内联等。

指令重排序需要满足as-if-serial语义原则,即经过重排序后生成的指令序列不能改变单线程程序的执行结果,也不能影响程序的并发行为。为了保证这个原则,编译器和处理器会遵循一定的原则进行指令重排序,只有在它们认为重排序后不会对程序产生影响的情况下才会执行这个优化操作。

需要注意的是,虽然指令重排序看起来是一个非常高级的优化技术,但是由于其依赖于硬件架构和编译器实现策略,且优化效果不一而足,所以不同的处理器、操作系统和编译器可能会对其具体实现方式有所不同。


1.2 加锁

使用加锁,可以解决一定程度的线程安全问题,但是这并不代表加锁一定可以解决线程安全问题

具体问题还是需要具体分析

1.2.1 synchronized 的使用方式

synchronized 有以下两种使用方式:

1.修饰方法:可以将 synchronized 关键字直接作用在方法上,表示该方法是一个同步方法,其整个方法体都是同步代码块。

例如:

public synchronized void add(int value) {
        // 同步代码块
    }

2.修饰代码块:也可以将 synchronized 关键字作用在代码块上,表示该代码块是一个同步代码块。需要传入一个对象作为锁,只有持有该锁的线程可以访问被保护的代码区域。
 例如:

public void add() {
        synchronized (this) {
            // 修饰代码块
        }
    }

 synchronized () 这个括号里面放你要加锁的对象

加锁的范围就是 synchronized 的 {} 里面,进入 {} 就加锁,出 {} 就解锁


1.2.2 synchronized 的特性

synchronized 主要特性包括以下几点:

  1. 原子性:synchronized 保证同步代码块或方法整体执行的原子性,即同一时刻只有一个线程可以获取锁并执行同步代码。

  2. 可重入性:synchronized 具备可重入性,即在同一个线程中,可以多次获取同一个对象的锁,而不会造成死锁。

  3. 可见性:synchronized 保证同步代码块内对共享变量进行的修改可以被其他线程及时看到,并且同步代码块内的所有操作都是在主内存中进行的,而不是在各自的工作内存中进行的。

  4. 互斥性:synchronized 保证同一时刻只有一个线程可以获取锁并执行同步代码,从而避免了多个线程对共享资源的竞争和冲突。

需要注意的是,使用 synchronized 会带来一定的性能开销,因此应该尽可能缩小同步代码块的范围和粒度,避免过度同步导致性能下降。此外,在使用 synchronized 进行同步控制时还需要注意死锁等问题. 


 1.2.3 死锁

先来了解什么是死锁?

死锁是指:两个或多个进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进。死锁是多线程并发编程中常见的问题。

一旦程序出现死锁,就会导致线程无法继续后续的工作,程序势必有严重的 bug.

死锁非常隐蔽,开发阶段不经意间就会写出来,也不容易测试出来

1.2.3.1 死锁的三种情况:

1. 一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会死锁

Java 里 synchronized 和 ReentrantLock 都是可重入锁,故无法演示

2. 两个线程两把锁, t1 和 t2 各自针对 锁A 和 锁B 进行加锁,在尝试获取对方的锁

例如: 外出吃饭,A 的手边有筷子, B 的手边有调料,A 对 B 说: 你把调料给我,我就把筷子给你,

B 对 A 说:你把筷子给我,我就把调料给你,两个人都互不相让,此时就会死锁

这个可以演示,下面上代码

public class ThreadDemo5 {
    public static void main(String[] args) {
        Object A1 = new Object();
        Object B1 = new Object();

        Thread A = new Thread(() -> {
            synchronized (A1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (B1) {
                    System.out.println("A 解锁");
                }
            }
        });
        Thread B = new Thread(() -> {
            synchronized (B1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (A1) {
                    System.out.println("B 解锁");
                }
            }
        });
        A.start();
        B.start();
    }
}

 

互相阻塞谁也拿不到东西 

打开 JConsole 看看什么情况

 连接我的 ThreadDemo5

 不安全连接

 

 进入线程,查看 Thread-0,显示状态为: BLOCKED,拥有者: Thread-1

在瞅瞅我们的 Thread-1

 Thread-1,显示状态为: BLOCKED,拥有者: Thread-0

这俩开始互锁了

破解方案就是其中一个松手即可

public class ThreadDemo5 {
    public static void main(String[] args) {
        Object A1 = new Object();
        Object B1 = new Object();

        Thread A = new Thread(() -> {
            synchronized (A1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (B1) {
                    System.out.println("A 解锁");
                }
            }
        });
        Thread B = new Thread(() -> {
            synchronized (A1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (B1) {
                    System.out.println("B 解锁");
                }
            }
        });
        A.start();
        B.start();
    }
}

 可别两个都松手,那样就是新一轮互锁

3.多个线程,多把锁

经典的:哲学家就餐问题

假设有5个哲学家围坐在一张圆桌旁,每个哲学家面前放着一盘面条和一只筷子。这5个哲学家只有两种状态:思考和就餐。每次就餐需要同时持有自己左右两边的筷子,但系统中只有5只筷子,因此哲学家们必须按照特定的顺序持有筷子才能就餐。


 假设此时出现极端情况,同一时刻,所有的哲学家都拿起左手的东西,所有的哲学家都拿不起右手的筷子,所有的哲学家都要等待右边的哲学家把筷子放下,反之也是如此

解决方法就是给筷子进行编号

 例如:现在规定:只能先拿编号小的(或者先拿大的),现在每个哲学家先尝试获取编号较小的筷子,如果成功获取,则再尝试获取编号较大的筷子。如果获取失败,则释放已经获得的筷子,并等待一段时间后再次尝试。


1.2.3.2 死锁形成的四个必要条件:

总结一下死锁形成的四个必要条件:

1.互斥占用: 线程1 拿到了锁, 线程2 就要等待(锁的基本特性)

2.不可抢占: 线程1 拿到了锁之后, 必须是 线程1 主动释放, 不能是 线程2 强行把锁获取到

3.请求和保持: 线程1 拿到锁A 之后,在尝试获取 锁B, A 这把锁,还是保持的(不会因为获取锁 B 就把 A 释放调)

4. 循环等待: 线程1 尝试获取到 锁A 和 锁B ,线程2 尝试获取到 锁A 和 锁B,线程1 在获取到 B 的时候等待线程2 释放 B,线程2 在获取到 A 的时候等待线程1 释放 A

以上四个条件,实际上就是一个条件

前三个条件就是锁的基本特性(对于 synchronized 这把锁来说,前三点,动不了)

循环等待,是程序员唯一可以控制的

例如,给锁编号,然后指定一个固定的顺序(从小到大)来加锁


1.3 Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的 . 这些类可能会涉及到多线程修改共享数据 , 又没有任何加锁措施 .
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
上面这些类在使用时需要注意线程安全问题,需要手动加锁
但是还有一些是线程安全的 . 使用了一些锁机制来控制
Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer

还有的虽然没有加锁 , 但是不涉及 " 修改 ", 仍然是线程安全的
String

1.4 volatile 关键字

1.4.1 volatile 特性

当一个变量被声明为 volatile 后,它具有以下特点:

  1. 可见性:被标记为 volatile 的变量,对于所有的线程都是可见的,也就是说,一个线程修改了变量的值,其他线程会立即感知到这个变化。

  2. 有序性:被标记为 volatile 的变量,其读取和写入操作都是有序的,也就是说,一个线程写入变量的值后,其他线程读取这个变量的值时,总是能够按照正确的顺序看到最新的值。

需要注意的是,volatile 变量只保证单个变量的原子性,而不能保证多个 volatile 变量之间的原子性。如果需要保证多个变量之间的原子性,可以考虑使用 synchronized 或者 Lock 等同步机制。

另外,由于 volatile 变量会频繁地刷新到主内存中,所以使用过多的 volatile 变量也可能会带来性能上的损失。

1.4.2 内存可见性

下面使用代码来详细看看 volatile 的可见性,具体是个什么东西

这段代码没有使用 volatile

想实现的功能是, t1 线程在 flag==0 的情况下一直空循环

当 t2 线程输入一个非0的整数的时候,让 t1 线程结束循环

class MyCounter{
    public int flag =0;
}

public class ThreadDemo3 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while (myCounter.flag == 0){
                //空循环
            }
            System.out.println("t1 结束循环");
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            myCounter.flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

但是,当我们运行代码以后,发现事情不是按照我们的预期在发展

输入前

 输入后

 我们发现代码一样在运行,并没有停止

这是为什么呢?

我们打开 JConsole 让程序重新运行一遍,看看到底哪里出了问题

老规矩,直接搜 JConsole,打开他,点击你的程序

我的是 ThreadDemo3

 

 点击"不安全的链接"

先点击线程,然后找到下面的 Thread-0

这个就是 t1 线程

我们再看 Thread-1

这是 t2 线程

此时 IDEA 没有进行输入操作,两个线程都是阻塞(虽然状态写的 RUNNABLE) 

IDEA 上面,我们输入一个 非0整数,再看一眼 JConsole

会发现 Thread-1 就是我们的 t2 线程 消失了

但是我们的 Thread-0 就是我们的 t1 线程还在

这就是内存可见性问题 

1.4.2.1 从汇编的角度来看内存可见性问题

先来简单认识一下,什么是汇编:

汇编语言是一种低级的计算机编程语言,用于直接描述/控制 CPU 的运行方式。它将机器指令以一种易于理解和编写的形式呈现出来,通过使用助记符号(mnemonics)来代替操作码(opcode),并且支持使用标号、变量等高级概念,使得程序员可以直接与硬件交互,实现对计算机底层设备的控制。

从汇编的角度来看内存可见性问题:

从汇编的角度来看,共享变量的内存可见性问题主要是因为 CPU 的缓存机制导致的。当一个线程想要访问某个共享变量时,它会首先从自己的 CPU 缓存中读取该变量的值,如果缓存中没有该变量的值,则需要从主内存中读取。

而当一个线程修改共享变量的值时,这个修改可能不会立即写入到主内存中,而是会先写入到该线程的 CPU 缓存中,也就是所谓的“写缓存”。这样,其他线程在读取该变量时有可能会读取到旧的值,因为它们并不知道该变量的值已经被修改了。

 单线程的情况下 JVM 好判断, 多线程的情况下 JVM 判断不一定准确,就会导致问题,这就是内存可见性问题

简单理解:内存可见性问题:一个线程针对一个变量进行读取操作,同时另一个线程针对同一个变量进行修改,此时读取到的值,不一定是修改后的值

这个读线程没有感知到变量的变化

归根结底就是编译器/ JVM 在多线程环境下优化产生了误判

这个时候就需要程序员手动干预了,可以给 flag 这个变量加上 volatile 关键字,就是告诉编译器,这个变量是"易变"的,你一定要每次都读取这个变量的内存内容,指不定啥时候就变了,不能激进优化

我们给他加上 volatile 看看效果 

class MyCounter{
    volatile public int flag =0;
}

public class ThreadDemo3 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(()->{
            while (myCounter.flag == 0){
                //空循环
            }
            System.out.println("t1 结束循环");
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            myCounter.flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 此时就正常结束了

如果不想加 volatile, 我们还有个其他的好玩的改法

 我们加入 sleep 一样可以

class MyCounter {
    public int flag = 0;
}

public class ThreadDemo3 {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Thread t1 = new Thread(() -> {
            while (myCounter.flag == 0) {
                //空循环
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 结束循环");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            myCounter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

如果玩出事了,别说我教的

如果玩出事了,别说我教的

如果玩出事了,别说我教的

 言归正传

1.4.2.2 从 JMM 的角度来看内存可见性问题 

我们先简单认识一下什么是JMM:

JMM(Java Memory Model)是 Java 并发编程中的一种内存模型,它规定了多线程之间共享变量的可见性、有序性和原子性等行为。JMM 主要解决的问题是在多线程环境下,由于线程之间的竞争和交错执行,可能导致程序出现一些非预期的结果,如线程安全问题、死锁、活锁等。

从 JMM 的角度来看内存可见性问题:

JMM 通过对变量的读写操作进行限制和约束,确保了在多线程环境下对共享变量的操作具有可见性和原子性。具体地说,JMM 通过以下两个核心原则来保证内存可见性:

  1. 线程解锁之前,必须把共享变量的最新值刷新到主内存中;
  2. 线程加锁时,将清空工作内存中共享变量的值,从而使用该变量时需要从主内存中重新读取。

根据这两个原则,当一个线程修改了共享变量的值之后,它需要将该值刷新到主内存中,这样其他线程在读取该变量的值时就可以从主内存中获取到最新的值,从而保证了内存可见性。

Java程序里,既有主内存,还有每个线程自己的工作内存,(t1 和 t2的工作内存不是一个东西)

t1 线程进行读取的时候,只是读取了工作内存的值

t2 线程进行修改的时候,先修改了工作内存,再把工作内存同步到主内存中

但是由于编译器的优化.导致 t1 没有重新的从主内存同步数据到工作内存,读到的结果就是"修改之前"的结果

上面红色的话,看着很别扭,这是因为翻译问题

当你把 "主内存(main memory)" 换成 "内存"

把 "工作内存(work memory)" 换成 "CPU寄存器"

work memory 不是指内存,指的是 CPU 上存储数据的单元(寄存器)

为啥 Java 不直接叫做 "CPU内存寄存器" 呢?而是叫做 "工作内存" 呢?

因为工作内存,不一定只是 CPU寄存器, 还有可能包括 CPU 的缓存 cache

有的 CPU 还可能没有 cache,有的可能有一个,有的可能有多个

而且引入 cache 以后,硬件结构也变复杂了

工作内存(工作存储区)=CPU 寄存器+CPU 的 cache

所以一方面为了表述简单,另一方面也是为了避免涉及到硬件的细节和差异,Java 这里就使用"工作内存"这个词,一言蔽之


1.4.3 synchronized 和volatile 的区别

synchronized 和volatile 的区别

synchronized用于保证在同一时刻只有一个线程可以访问被synchronized关键字保护的代码块或方法。当一个线程进入synchronized代码块时,它会尝试获取锁。如果另一个线程已经持有了这个锁,那么第一个线程就必须等待。只有当第一个线程释放锁后,其他线程才能进入同步代码块。

相比之下,volatile用于确保可见性和有序性。如果一个变量被声明为volatile,那么当一个线程修改了这个变量的值时,所有正在访问该变量的线程都会看到这个变化。此外,对volatile变量的写入操作会被立即刷入内存,而对普通变量则不作任何保证。这就确保了volatile变量的修改对于所有线程都是可见的。另外,volatile还可以防止指令重排,因此可以确保代码中各变量的赋值和计算顺序与执行顺序一致。

因此,虽然synchronized和volatile都可以用于多线程编程,但是它们的作用是不同的。synchronized用于控制临界区的并发访问,而volatile用于确保变量的修改对于所有线程都是可见的,并且可以防止指令重排。

在最开始的时候说特性的时候,说了一句:

volatile 变量只保证单个变量的原子性,而不能保证多个 volatile 变量之间的原子性

下面代码演示一下,还是使用上一篇博客在讲到 synchronized 方法的时候使用的演示代码,只不过加锁变成了 volatile 方法

public static void main(String[] args) throws InterruptedException {
        class Counter {
            volatile public int count = 0;

            void add() {
                count++;
            }
        }
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }

 要想稳定得到十万,还是需要使用 synchronized 方法


 本文完,感谢观看,有什么错误和不足的地方请在评论区指出,一起进步,谢谢 !

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值