你好,我是田哥
今天,一位朋友跟我反馈,在面试中被问到并发编程的特性有哪些?

可惜的是面试官没有展开问,只是随口一问,我也在想这位面试官估计也不是很看重这一块,看重的是业务处理能力(上面那位朋友反馈的是:面试官很喜欢问项目的问题)。说实话,这个问题要是全面真的展开讲,你可以和面试官聊一天,或许都聊不完。
并发编程的三大问题,也叫三大特性,也有的叫三大因素:
原子性
可见性
有序性
我们本文就用代码来演示一下这三个老东西,理论形式看完很容易忘掉,所以我想通过这种代码的方式和你聊聊。
什么叫原子性?
一个操作或者多个操作,要么全部执行成功,要么全部执行失败。满足原子性的操作,中途不可被中断。
整个案例:
- package com.tian.utils;
-
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 原子性演示
- * @公众号 Java后端技术全栈
- */
- public class AtomDemo {
- private static int a = 0;
-
- public static void incr() {
- try {
- //睡一会 更好看出问题
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- a++;
- }
-
- public static void main(String[] args) {
- //1000个线程
- for (int i = 0; i < 1000; i++) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- incr();
- }
- });
- thread.start();
- }
- try {
- //主线程等10秒
- Thread.sleep(10000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(a);
- }
- }
执行上面这段代码,你会发现输出的结果并不是固定,这就是原子性的问题。
什么地方的原子性问题呢?
a++
为什么说a++就不是原子操作呢?我们可以将这个操作拆分为3个步骤。
1、线程从主内存把遍历加载到缓存。
2、线程执行a++操作。
3、线程将a的新值刷新到主内存。
如果此时两个线程来做a++,假设a初始值等于0,线程1拿到a=0,线程2此时也来了,拿到a=0,然后线程1对其进行a++,把a=1刷新到主内存中,最后线程2也对a++,也把a=1刷新到主内存中。
也就是原本来个线程执行a++,理论结果是a=2,但a=1了。
我们可以通过synchronized关键和AtomicInteger 来解决上面的原子性问题。
synchronized解决方案:
- package com.tian.utils;
-
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 原子性演示
- * @公众号 Java后端技术全栈
- */
- public class AtomDemo {
- private static int a = 0;
- //incr()方法添加synchronized关键字的修饰
- public synchronized static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- a++;
- }
-
- public static void main(String[] args) {
- //10个线程
- for (int i = 0; i < 1000; i++) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- incr();
- }
- });
- thread.start();
- }
- try {
- Thread.sleep(5000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(a);
- }
- }
AtomicInteger解决方案:
- import java.util.concurrent.atomic.AtomicInteger;
-
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 原子性演示
- * @公众号 Java后端技术全栈
- */
- public class AtomDemo {
- //初始值为0
- private static AtomicInteger a = new AtomicInteger(0);
-
- public static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- //自增+1
- a.incrementAndGet();
- }
-
- public static void main(String[] args) {
- //10个线程
- for (int i = 0; i < 1000; i++) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- incr();
- }
- });
- thread.start();
- }
- try {
- Thread.sleep(5000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(a);
- }
- }
到这里,关于原子性问题应该不难理解了吧。
另外,很多人对于volatile关键字不能保证原子性的问题,也是不太理解。
我们在回到上面的案例中,如果我们没有使用到synchronized和AtomicInteger来解决,使用volatile来试试。
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 原子性演示
- * @公众号 Java后端技术全栈
- */
- public class AtomDemo {
- //使用volatile
- private volatile static int a = 0;
-
- public static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- a++;
- }
-
- public static void main(String[] args) {
- //10个线程
- for (int i = 0; i < 1000; i++) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- incr();
- }
- });
- thread.start();
- }
- try {
- Thread.sleep(5000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(a);
- }
- }
运行结果可能是1000,但只要你多次运行上面这段代码,你会发现结可能会少于1000。所以,volatile并不能保证i++之类的原子性。
什么叫可见性?多个线程共同访问共享变量时,某个线程修改了此变量,其他线程能立即看到修改后的值。
整个案例:
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 可见性演示
- * @公众号 Java后端技术全栈
- */
- public class VisibleDemo {
-
- private static boolean flag = false;
-
- public static void main(String[] args) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!flag) {
- i++;
- }
- System.out.println(i);
- }
- });
- thread.start();
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- flag = true;
- }
- }
上面这段代码的逻辑很简单,定义一个静态变量flag。main方法中创建一个子线程,子线程中通过判断flag的值来对i进行++操作,最后输出,启动子线程。主线程先睡1000毫秒,然后把flag设置为true。
主线程和子线程共享变量flag,可见性就是主线程把flag改成true后,子线程能不能看到。
上面的这段代码,运行结果是子线程一直死循环,没有任何输出。

最直观的解决方案就是给flag变量加上volatile关键字修饰。
- /**
- * @author tianwc
- * @version 1.0.0
- * @description 可见性演示
- * @公众号 Java后端技术全栈
- */
- public class VisibleDemo {
-
- private volatile static boolean flag = false;
-
- public static void main(String[] args) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!flag) {
- i++;
- }
- System.out.println(i);
- }
- });
- thread.start();
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- flag = true;
- }
- }
再次运行上面这段代码:

正常的输出结果了,说明while判断中flag已经变成了true,也就是主线程对flag的修改,子线程是可以知道的啦。
田哥在写这个案例的时候,发现一丢丢问题,不知道你会怎么想,请看问题:
- public class VisibleDemo {
- //没有volatile关键字修饰
- private static boolean flag = false;
-
- public static void main(String[] args) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!flag) {
- i++;
- System.out.println(i);//把输出提到while里
- }
- }
- });
- thread.start();
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- flag = true;
- }
- }
你觉得上面这段代码运行结果是怎样的?
再来一个问题,请看代码:
- public class VisibleDemo {
-
- private static boolean flag = false;
-
- public static void main(String[] args) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!flag) {
- i++;
- //在while添加Thread.sleep()
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- System.out.println(i);
- }
- });
- thread.start();
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- flag = true;
- }
- }
这段代码运行结果又是怎么样的?
不绕弯子了,这里两种都会运行结束。


什么是有序性?程序执行的顺序按照代码的先后顺序执行。
(由于JMM模型中允许编译器和处理器为了效率,进行指令重排序的优化。指令重排序在单线程内表现为串行语义,在多线程中会表现为无序。那么多线程并发编程中,就要考虑如何在多线程环境下可以允许部分指令重排,又要保证有序性)
整个案例:
- int num = 0;
- boolean ready = false;
- // 线程1 执行此方法
- public void action1(I_Result r) {
- if(ready) {
- r.r1 = num + num;
- } else {
- r.r1 = 1;
- }
- }
- // 线程2 执行此方法
- public void action2(I_Result r) {
- num = 2;//
- ready = true;//
- }
上面这段代码,最终r.r1结果是多少?
下面我们来分析一下:
情况1:线程1 先执行,此时 ready = false,所有进入else ,结果为1
情况2:线程2 先执行 num = 2,但还没来得及执行 ready = true,线程1 开始执行,还是进入else ,结果为1
情况3:线程2 先执行到ready = true,线程1 执行,进入else ,结果为4
情况4:由于指令重排序,线程2先执行 ready = true。切换到线程1,进入 if 分支,相加为0,再切回线程2,执行 num = 2,结果为0。
我们在来看一个案例:单例模式
单列模式的写法有很多种,我之前也分享过:
这里我们说的单例模式是双重检查锁单例模式。
- public class SingleLazy {
- private SingleLazy() {}
- private static SingleLazy INSTANCE;
- // 获取实体
- public static SingleLazy getInstance() {
- // 实例未被创建,开启同步代码块准备创建
- if (INSTANCE == null) {
- synchronized (SingleLazy.class) {
- // 也许其他线程在判断完后已经创建,再次判断
- if (INSTANCE == null) {
- INSTANCE = new SingleLazy();
- }
- }
- }
- return INSTANCE;
- }
- }
看下程序运行流程:
假设两个线程 t1、t2 同时调用 getInstance(),同时发现INSTANCE == null;
于是同时对 SingleLazy.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 t1),另外一个线程则会处于等待状态(假设是线程 t2);
线程 t1 会创建一个 SingleLazy 实例,之后释放锁,锁释放后,线程 t2 被唤醒,线程t2 再次尝试加锁,此时是可以加锁成功的;
t2 加锁成功后,线程 t2 再次检查 INSTANCE == null 是否成立,发现 INSTANCE!=null,不会再去实例化INSTANCE,直接返回线程t1实例化的INSTANCE。
有没有觉得这不挺好的么?怎么扯到有序性这里来了。
创建一个对象其实需要三步:
- memory = allocate();//1.分配对象内存空间
- instance(memory);//2.初始化对象
- instance = memory;//3.设置instance指向刚分配的内存地址,此时instance e != null
其中第一步和第二步可能会发生指令重排导致安全性问题:
如果发生重排序,会导致什么问题呢?
依然假设线程 t1 先去执行 getInstance() 方法(和线程t2并不是同时去执行),直接获取到锁,并执行实例化new操作,当执行完指令 2 时恰好发生了线程切换,切换到了线程 t2 上;如果此时线程 t2 也执行 getInstance() 方法,那么线程 t2 在执行第一个判断时会发现 INSTANCE!= null ,所以直接返回 INSTANCE,而此时的 INSTANCE是没有初始化过的,如果我们这个时候访问 INSTANCE的成员变量就可能触发空指针异常。
这类重排序也成为编译优化带来的有序性问题。
- public class SingleLazy {
- private SingleLazy() {}
- //添加volatile
- private volatile static SingleLazy INSTANCE;
- // 获取实体
- public static SingleLazy getInstance() {
- // 实例未被创建,开启同步代码块准备创建
- if (INSTANCE == null) {
- synchronized (SingleLazy.class) {
- // 也许其他线程在判断完后已经创建,再次判断
- if (INSTANCE == null) {
- INSTANCE = new SingleLazy();
- }
- }
- }
- return INSTANCE;
- }
- }
此时,我们只需要添加volatile修饰INSTANCE即可解决重排序带来的问题。
注意: JDK1.5前的 volatile 关键字不保证指令重排问题
如果面试官问volatile,记得把上面这个JDK版本的提出来说一下,不然怎么显得你比别人知道的多呢
?
好了,关于并发编程的三大问题就聊这些。本文只是为了演示,所以没有做太深入的讲解,这里面会涉及到Java内存模型,还会涉及到JVM源码。
我之前画过一个《并发编程核心知识》思维导图;

思维导图可以让你构建自己的知识学习路径,学完后很多技术可能不怎么用,很快就容易忘掉,思维导图就是最好的快速复习资料。
免费获取并发编程核心知识思维导图,请加我加我微信:tj20120622,备注“并发”。
到目前为止,我已经花了快30张的思维导图。


另外,也在不断更新《Java后端核心知识总结》;

《面试小抄》第三版已完结,现已开始着手准备第四版。
《Java学习专栏》已更新771篇文章:
加入我的知识星球,即可获取上述所有资料,其实还有很多:







.......
记得加我微信,微信好友,我都给惊喜价。