Java并发编程的本质是解决这三大问题
【本文版权归微信公众号"代码艺术"(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】
前言
并发编程的本质其实是要解决:可见性、原子性、有序性这三大问题。
相信这句话你已经听了无数遍,那我问你,单核CPU是否有并发问题,是否还需要加锁呢?线程的工作内存在哪里,你可别给我说是栈。
本文就是想搞清楚一直的聊的并发编程问题,究竟是什么?
可见性
学过计算机组成原理的我们知道,计算机存储系统的层次结构主要体现在缓存 - 主存和主存 - 辅存这两个存储层次上,如图所示。显然,CPU 和缓存、主存都能直接交换信息;缓存能直接和CPU、主存交换信息;主存可以和CPU、缓存、辅存交换信息。

(注:主存指 RAM 和 ROM;辅存指光盘、磁带、磁盘等)
对于如今的多核处理器,CPU的每个内核都有自己的缓存,而缓存仅仅对它所在的处理器可见,所以缓存向主存刷新数据时就容易造成数据的不一致问题。如图所示。

在Java内存模型中提到了线程栈为线程的工作内存,其实线程的工作内存是对 CPU 寄存器和高速缓存的抽象描述,使用频率高的数据从主存拷贝到高速缓存中,每个线程在 CPU 高速缓存中对拷贝的数据进行读取、计算、赋值,再在合适的时候同步更新到主存的该数据。
所谓的可见性,就是一个线程对共享变量的修改,另外一个线程能够立刻看到。
导致可见性问题的原因就是缓存不能及时刷新至主存。
例如一段代码如下所示:
public class PrintString implements Runnable{
private boolean isContinuePrint = true;
@Override
public void run() {
while (isContinuePrint){
System.out.println("Thread: "+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public boolean isContinuePrint() {
return isContinuePrint;
}
public void setContinuePrint(boolean continuePrint) {
isContinuePrint = continuePrint;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(100);
System.out.println("我要停止它!" + Thread.currentThread().getName());
printString.setContinuePrint(false);
}
}
JVM有Client和Server两种模式,我们可以通过运行:java -version 来查看 JVM 默认工作在什么模式。我们在IDE中把 JVM 设置为在 Server 服务器的环境中,具体操作只需配置运行参数为 -server。然后启动程序,打印结果:
Thread begin: Thread-A
我要停止它!main
代码 System.out.println("Thread end: "+Thread.currentThread().getName());从未被执行。
是什么样的原因造成将JVM设置为 -server 就出现死循环呢?
在启动thread线程时,变量boolean isContinuePrint = true;存在于公共堆栈及线程的私有堆栈中。在JVM设置为 -server 模式时为了线程运行的效率,线程一直在私有堆栈中取得 isRunning 的值是 true。而代码 thread.setRunning(false); 虽然被执行,更新的却是公共堆栈中的 isRunning 变量值 false,所以一直就是死循环的状态。内存结构图:

这个问题其实就是线程工作内存中的值和主内存中的值不同步造成的。解决这样的问题就要使用volatile关键字了,它主要的作用就是当线程访问 isRunning 这个变量时,强制性从主内存中进行取值。内存结构图:

此图是不是和本文最开始所讲,CPU 可与主存直接进行信息交换一致呢,果然,JVM 内存模型只是对计算机系统的一层封装。
原子性
原子性是什么?
把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
为什么会有原子性问题?
线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
如:对于一段代码,一个线程还没执行完这段代码但是时间片耗尽,在等待CPU分配时间片,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程同时执行同一段代码,也就是原子性问题。
线程切换带来原子性问题。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
i = 0; // 原子性操作
j = i; // 不是原子性操作,包含了两个操作:读取i,将i值写回给j
i++; // 不是原子性操作,包含了三个操作:读取i值、i + 1 、将结果写回给i
i = j + 1;// 不是原子性操作,包含了三个操作:读取j值、j + 1 、将结果写回给i
自增操作实际是 3 个离散操作的简写形式:获取当前值,加 1,写回新值。这是一个“读-改-写”操作的实例。
要想保证自增操作的原子性,可以在自增操作中使用 synchronized 关键字进行加锁,代码如下:
public class ThreadTest extends Thread {
int i = 0;
@Override
public void run(){
for (int j = 0; j < 1000; j++) {
synchronized (ThreadTest.class) {
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadTest threadTest = new ThreadTest();
for (int i = 0; i < 10; i++) {
new Thread(threadTest).start();
}
Thread.sleep(2000);
System.out.println(threadTest.i);
}
}
这段程序的作用是将 int 变量 i 通过 10 个线程累加到 10000,运行后可以看到程序的结果符合我们的预期,原因分析如下:

上面我们说了线程拥有自己的工作内存(寄存器或缓存),但是上图中只标识出写入内存,因为 synchronized 不止可以保证我们“读-改-写”操作的原子性,还可以保证内存的可见性,即由 CPU 直接对主存进行信息交换。
有序性
【本文版权归微信公众号"代码艺术"(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】
有序性:程序执行的顺序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:a=6;b=7;编译器优化后可能变成b=7;a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的效果。
有序性问题举例
Java中的一个经典的案例:利用双重检查锁创建单例对象。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。
看似很完美,既保证了线程完全的初始化单例,又经过判断 instance 为 null 时再用synchronized 同步加锁。但是还有问题!
instance = new Singleton(); 创建对象的代码,分为三步:
①分配内存空间
②初始化对象Singleton
③将内存空间的地址赋值给 instance
但是这三步经过重排之后:
①分配内存空间
②将内存空间的地址赋值给instance
③初始化对象Singleton
会导致什么结果呢?
线程 A 先执行 getInstance() 方法,当执行完指令 ② 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance!=null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
时序图如下:

解决这个问题的方法就是使用 volatile关键字。对修饰变量的操作不会与其他的内存操作一起重排序,即其具有禁止指令重排序的功能。
问题
回到最初的问题,单核CPU是否有并发问题,是否还需要加锁呢?
学习到这里,相信你已经明白了,单核CPU只能说具有天然的内存可见性,但并发问题涉及的原子性和有序性,依旧还是需要自行解决。
声明
本文所述观点如有不足请留言告知,多谢。
参考资料
https://mp.weixin.qq.com/s/rkl916p8RIErGn58DNcihw
版权声明
【本文版权归微信公众号"代码艺术"(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

Java并发编程的本质是解决这三大问题的更多相关文章
- Java并发编程实战 02Java如何解决可见性和有序性问题
摘要 在上一篇文章当中,讲到了CPU缓存导致可见性.线程切换导致了原子性.编译优化导致了有序性问题.那么这篇文章就先解决其中的可见性和有序性问题,引出了今天的主角:Java内存模型(面试并发的时候会经 ...
- Java并发编程——线程安全及解决机制简介
简介: 本文主要介绍了Java多线程环境下,可能会出现的问题(线程不安全)以及相应的解决措施.通过本文,你将学习到如下几块知识: 1. 为什么需要多线程(多线程的优势) 1. 多线程带来的问题—线程安 ...
- Java并发编程实战 03互斥锁 解决原子性问题
文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...
- Java并发编程实战 04死锁了怎么办?
Java并发编程文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 前提 在第三篇 ...
- Java并发编程实战 05等待-通知机制和活跃性问题
Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...
- Java并发编程 | 从进程、线程到并发问题实例解决
计划写几篇文章讲述下Java并发编程,帮助一些初学者成体系的理解并发编程并实际使用,而不只是碎片化的了解一些Synchronized.ReentrantLock等技术点.在讲述的过程中,也想融入一些相 ...
- Java并发编程:Synchronized及其实现原理
Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) Java 并发编程 ...
- Java并发编程 Volatile关键字解析
volatile关键字的两层语义 一旦一个共享变量(类的成员变量.类的静态成员变量)被volatile修饰之后,那么就具备了两层语义: 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了 ...
- Java 并发编程:volatile的使用及其原理
Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) Java 并发编程 ...
随机推荐
- HTTP 规范中的那些暗坑
HTTP 协议可以说是开发者最熟悉的一个网络协议,「简单易懂」和「易于扩展」两个特点让它成为应用最广泛的应用层协议. 虽然有诸多的优点,但是在协议定义时因为诸多的博弈和限制,还是隐藏了不少暗坑,让人一 ...
- Robot Framework(13)- RF 循环的详细使用
如果你还想从头学起Robot Framework,可以看看这个系列的文章哦! https://www.cnblogs.com/poloyy/category/1770899.html 前言 RF用 ...
- [Unity2d系列教程] 003.Unity如何调用android的方法
Unity开发的时候很多时候我们需要用到底层的一些功能,比如摄像,录音,震动等等,我们在Unity的层面是无法完成的.那么我们考虑到Unity是否可以直接调用到android方面的方法,替我们去完成我 ...
- 10 个提高效率的 Linux 命令别名
在 Linux 环境下工作的工程师,一定会对那些繁琐的指令和参数命令行印象深刻吧.而且,可怕的不是繁琐,而是需要大量重复输入这些繁琐的命令. 在 Linux 下我们有个别名命令 alias ,可以将那 ...
- Java实现 蓝桥杯 算法提高 小X的购物计划
试题 算法提高 小X的购物计划 问题描述 小X打算去超市shopping.小X没什么钱,只有N元.超市里有M种物品,每种物品都需要money,在小X心中有一个重要度.有的物品有无限件,有的物品只有几件 ...
- C# Winform退出程序的方法介绍
这篇文章主要介绍了C#中WinForm程序退出方法, 实例总结了技巧退出WinForm程序窗口的各种常用技巧,非常具有实用价值,需要的朋友可以参考下 本文实例总结了C#中WinForm程序退出方法技巧 ...
- Java实现 蓝桥杯VIP 算法训练 筛选号码
算法训练 筛选号码 Description 有n个人围成一圈,顺序排号(编号为1到n).从第1个人开始报数(从1到3报数),凡报到3的人退出圈子.从下一个人开始继续报数,直到剩下最后一个人,游戏结束. ...
- Java实现预排序
1 问题描述 在计算机科学中,预排序是一种很古老的思想.实际上,对于排序算法的兴趣很大程度上是因为这样一个事实:如果列表是有序的,许多关于列表的问题更容易求解.显然,对于包含了排序操作,这种算法的时间 ...
- Java实现选择问题
选择问题是求一个n个数列表的第k个最小元素的问题. 那么如何寻找n个元素中第k个最小元素呢? package com.liuzhen.chapter4; public class SelectProb ...
- 信道估计(channel estimation)图解——从SISO到MIMO原理介绍
1. 引言 在所有通信中,信号都会通过一个介质(称为信道),并且信号会失真,或者在信号通过信道时会向信号中添加各种噪声.正确解码接收到的信号而没有太多错误的方法是从接收到的信号中消除信道施加的失真和噪 ...