【本文版权归微信公众号"代码艺术"(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并发编程的本质是解决这三大问题的更多相关文章

  1. Java并发编程实战 02Java如何解决可见性和有序性问题

    摘要 在上一篇文章当中,讲到了CPU缓存导致可见性.线程切换导致了原子性.编译优化导致了有序性问题.那么这篇文章就先解决其中的可见性和有序性问题,引出了今天的主角:Java内存模型(面试并发的时候会经 ...

  2. Java并发编程——线程安全及解决机制简介

    简介: 本文主要介绍了Java多线程环境下,可能会出现的问题(线程不安全)以及相应的解决措施.通过本文,你将学习到如下几块知识: 1. 为什么需要多线程(多线程的优势) 1. 多线程带来的问题—线程安 ...

  3. Java并发编程实战 03互斥锁 解决原子性问题

    文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...

  4. Java并发编程实战 04死锁了怎么办?

    Java并发编程文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 前提 在第三篇 ...

  5. Java并发编程实战 05等待-通知机制和活跃性问题

    Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...

  6. Java并发编程 | 从进程、线程到并发问题实例解决

    计划写几篇文章讲述下Java并发编程,帮助一些初学者成体系的理解并发编程并实际使用,而不只是碎片化的了解一些Synchronized.ReentrantLock等技术点.在讲述的过程中,也想融入一些相 ...

  7. Java并发编程:Synchronized及其实现原理

    Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) Java 并发编程 ...

  8. Java并发编程 Volatile关键字解析

    volatile关键字的两层语义 一旦一个共享变量(类的成员变量.类的静态成员变量)被volatile修饰之后,那么就具备了两层语义: 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了 ...

  9. Java 并发编程:volatile的使用及其原理

    Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) Java 并发编程 ...

随机推荐

  1. [python爬虫]简单爬虫功能

    在我们日常上网浏览网页的时候,经常会看到某个网站中一些好看的图片,它们可能存在在很多页面当中,我们就希望把这些图片保存下载,或者用户用来做桌面壁纸,或者用来做设计的素材. 我们最常规的做法就是通过鼠标 ...

  2. python 操作txt 生成新的文本数据

    name: Jack ; salary: 12000 name :Mike ; salary: 12300 name: Luk ; salary: 10030 name :Tim ; salary: ...

  3. php序列化和反序列化学习

    1.什么是序列化 序列化说通俗点就是把一个对象变成可以传输的字符串. 1.举个例子,不知道大家知不知道json格式,这就是一种序列化,有可能就是通过array序列化而来的.而反序列化就是把那串可以传输 ...

  4. Java实现 LeetCode 740 删除与获得点数(递推 || 动态规划?打家劫舍Ⅳ)

    740. 删除与获得点数 给定一个整数数组 nums ,你可以对它进行一些操作. 每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数.之后,你必须删除每个等于 nums[ ...

  5. Java实现蓝桥杯VIP 算法训练 P0502

    试题 算法训练 P0502 资源限制 时间限制:1.0s 内存限制:256.0MB 编写一个程序,读入一组整数,这组整数是按照从小到大的顺序排列的,它们的个数N也是由用户输入的,最多不会超过20.然后 ...

  6. Java实现 LeetCode 72 编辑距离

    72. 编辑距离 给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 . 你可以对一个单词进行如下三种操作: 插入一个字符 删除一个字符 替换一个字 ...

  7. java实现罗马数字转十进制

    古罗马帝国开创了辉煌的人类文明,但他们的数字表示法的确有些繁琐,尤其在表示大数的时候,现在看起来简直不能忍受,所以在现代很少使用了.之所以这样,不是因为发明表示法的人的智力的问题,而是因为一个宗教的原 ...

  8. java实现第五届蓝桥杯圆周率

    圆周率 数学发展历史上,圆周率的计算曾有许多有趣甚至是传奇的故事.其中许多方法都涉及无穷级数. 图1.png中所示,就是一种用连分数的形式表示的圆周率求法. 下面的程序实现了该求解方法.实际上数列的收 ...

  9. Spring Data JPA入门及深入

    一:Spring Data JPA简介 Spring Data JPA 是 Spring 基于 ORM 框架.JPA 规范的基础上封装的一套JPA应用框架,可使开发者用极简的代码即可实现对数据库的访问 ...

  10. mac下使用VMVARE安装win10虚拟机的一些坑

    最近Mac上安装windows踩到了几个坑: 坑一:启动虚拟机后,提示找不到CD-ROM中找不到对应的ISO文件 硬盘格式请选择 在虚拟机->设置中选择启动磁盘为CD_ROM,然后重新启动. 坑 ...