Java高并发编程实战,原子性、可见性、有序性,傻傻分不清

本文深入探讨Java并发编程中的关键概念:原子性、可见性和有序性。通过实例分析,揭示了多线程环境下可能出现的问题,并提出了解决方案,包括使用synchronized、volatile关键字以及Java内存模型中的原则。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一、原子性

二、可见性

1、串行

2、单核CPU

3、多线程多CPU时的可见性问题

4、看下面一段代码,猜猜看删除结果

三、有序性

四、解决方案

一、原子性

原子性指操作在CPU执行的过程中,不可中断,也不可在中途切换,要么执行完成、要么不执行。

简单地分析一下原子性问题,写一段大众代码,如下:

package com.nezha.thread;

/**

*/

public class ThreadAtomicityTest {

private int step;

public int getStep(){

return step;

}

public void increaseStep(){

step++;

};

}

看不出什么问题,都这么写啊。

使用JDK自带的javap查看一下程序的指令码:

D:\MyProject\target\classes\com\nezha\thread>javap -c ThreadAtomicityTest.class

Compiled from "ThreadAtomicityTest.java"

public class com.nezha.thread.ThreadAtomicityTest {

public com.nezha.thread.ThreadAtomicityTest();

Code:

0: aload_0

1: invokespecial #1 // Method java/lang/Object."<init>":()V

4: return

public int getStep();

Code:

0: aload_0

1: getfield #2 // Field step:I

4: ireturn

public void increaseStep();

Code:

0: aload_0

1: dup

2: getfield #2 // Field step:I

5: iconst_1

6: iadd

7: putfield #2 // Field step:I

10: return

}

重点看一下increaseStep的指令码,大概包含三大步骤:

将变量step从内存中加载到CPU的寄存器中;

在CPU的寄存器中执行step++操作;

将step++后的结果写入缓存(CPU缓存或计算机内存);

线程切换可能发生在任何一条指令完成之后,而不是Java某条语句完成后。

假设线程1和线程2同时执行increaseStep()方法,在线程1执行过程中,CPU完成指令码的步骤①后发生了线程切换,此时线程2开始执行指令码的步骤①。

当两个线程都执行完整个increaseStep()方法后,得到的step的值是1而不是2。Why is this?

​如图所示,线程1将step=0加载到CPU的寄存器后,发生了线程切换。此时还没有执行step++操作,也没有将操作的结果写入内存,所以,内存中的step值仍为0。

线程2将step=0加载到CPU的寄存器中,执行step++操作,并将执行后的结果写入内存。此时,CPU切换到线程1继续执行,在执行线程1中的step++后,线程1中的step仍为1,线程1将step=1写入内存,最终内存中的step为1。

如果在CPU中存在正在执行的线程,此时,发生了线程切换,就可能导致并发编程的原子性问题。

所以,造成原子性问题的根本原因是在线程执行过程中发生了线程切换。

二、可见性

可见性指一个线程修改了共享变量,其它线程能够立刻读到共享变量的最新值。

在并发编程中,有两种情况能实现当一个线程修改了共享变量后,其它线程立刻就能读到最新值。

1、串行

​线程1和线程2是串行执行的,线程1写完数据后,线程2会从主内存中读取数据。线程1向主内存中写入数据对线程2是可见的,所以线程1和线程2之间不存在可见性问题。

2、单核CPU

在单核CPU中,多个线程之间也不会出现可见性问题。

在单核CPU中,只能有一个线程占用CPU资源来执行任务。当其它线程抢占CPU执行任务时,共享变量中的值一定是最新的。

3、多线程多CPU时的可见性问题

Java中,多个线程在读写内存中的共享变量时,会先把主内存中的共享变量数据复制到线程的工作内存中。每个线程在对数据进行读写操作时,都是直接操作自身的工作内存中的数据。由于每个线程都有自己的工作内存,所以线程1的数据对线程2是不可见的。线程1修改了数据,线程2不一定能够立刻读到修改后的数据,这就造成了可见性问题。

​4、看下面一段代码,猜猜看删除结果

package com.guor.demo.sync;

public class SynchronizedTest {

private static int count = 0;

public static void incremetCount(){

count++;

}

public static int increment() throws InterruptedException {

Thread thread1 = new Thread(()->{

for (int i = 0; i < 1000; i++) {

incremetCount();

}

});

Thread thread2 = new Thread(()->{

for (int i = 0; i < 1000; i++) {

incremetCount();

}

});

// 启动线程

thread1.start();

thread2.start();

thread1.join();

thread2.join();

return count;

}

public static void main(String[] args) throws InterruptedException {

System.out.println(SynchronizedTest.increment());

}

}

控制台输出:

为什么呢?

因为多个线程同时调用incremetCount()方法,出现了线程安全问题。

所以,造成可见性问题的根本原因是CPU缓存机制。

三、有序性

有序性指程序能够按照编写的代码顺序执行,不会发生跳过代码行的情况,也不会出现跳过CPU指令的情况。

那么什么时候会出现有序性问题呢?

为了提高程序的执行性能和编译性能,计算机和编译器有时候会修改程序的执行顺序。

在Java中一个典型的案例就是使用双重检测机制来创建单例对象。

package com.nezha.thread;

/**

* 线程不安全的单例模式

*/

public class SingleInstance {

private static SingleInstance instance;

public static SingleInstance getInstance(){

if(instance == null){

synchronized (SingleInstance.class){

if(instance == null){

instance = new SingleInstance();

}

}

}

return instance;

}

如果编译器和解释器不对上面的代码进行优化,也不改变程序的执行顺序,则代码的执行流程如下图所示:

如上图所示,假如线程1和线程2同时调用getInstance()方法获取对象实例,两个线程会同时发现instance为空,同时对SingleInstance.class加锁,而JVM会保证只有一个线程获取到锁,这里我们假设线程1获取到锁,线程2因为未获取到锁而进行等待。接下来,线程1再次判断instance对象为空,从而创建instance对象的实例,然后释放锁。此时,线程2被唤醒,再次尝试获取锁,获取锁成功后,线程2检查此时的instance对象已经不再是空,线程2不再创建instance对象。

上述流程看起来没有什么问题,但是,在高并发、大流量的场景下获取instance对象时,使用new关键字创建SingleInstance类的实例对象时,会因为编译器或解释器对程序的优化而出现问题。也就是说,问题的根源在于如下代码:

```instance = new SingleInstance();````

对于上面的代码包含三个步骤:

① 分配内存空间

② 初始化对象

③ 将instance引用指向内存空间

正常执行的CPU指令顺序为①②③,CPU对程序进行重排序后的执行顺序是①③②,此时就会出现问题。

​如上图所示,当线程1判断instance为空时,为对象分配内存空间,并将instance指向内存空间。此时还没有进行对象的初始化,发生了线程切换,线程2获取到CPU资源执行任务。线程2判断此时的instance不为空,则不再执行创建对象的操作,直接返回未初始化的instance对象。

所以,造成有序性问题的根本原因是编译器对程序进行优化,从而可能造成有序性问题。

四、解决方案

在Java中解决原子性问题的方案包括synchronized、Lock、ReentranLock、ReadWriteLock、CAS操作、Java中提供的原子类等。

解决可见性和有序性问题,可以禁用CPU缓存和编译器优化。

JVM提供了禁用缓存和编译优化的方法,包括volatile关键字、synchronized、final关键字以及Java内存模型中的Happens-Before原则。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值