Java 多线程:并发编程的三大特性

作者:Grey

原文地址:

博客园:Java 多线程:并发编程的三大特性

CSDN:Java 多线程:并发编程的三大特性

可见性

所谓线程数据的可见性,指的就是内存中的某个数据,假如第一个 CPU 的一个核读取到了,和其他的核读取到这个数据之间的可见性。

每个线程会保存一份拷贝到线程本地缓存,使用volatile,可以保持线程之间数据可见性。

如下示例

package git.snippets.juc;

import java.util.concurrent.TimeUnit;

/**
* 并发编程三大特性之:可见性
*
* @author <a href="mailto:410486047@qq.com">Grey</a>
* @since 1.8
*/
public class ThreadVisible { static volatile boolean flag = true; public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
System.out.println(Thread.currentThread() + " t start");
while (flag) {
// 如果这里调用了System.out.println()
// 会无论flag有没有加volatile,数据都会同步
// 因为System.out.println()背后调用的synchronized
// System.out.println();
}
System.out.println(Thread.currentThread() + " t end");
});
t.start();
TimeUnit.SECONDS.sleep(3);
flag = false; // volatile修饰引用变量
new Thread(a::m, "t2").start();
TimeUnit.SECONDS.sleep(2);
a.flag = false; // 阻塞主线程,防止主线程直接执行完毕,看不到效果
System.in.read();
} private static volatile A a = new A(); static class A {
volatile boolean flag = true; void m() {
System.out.println("m start");
while (flag) {
}
System.out.println("m end");
}
}
}

代码说明:

  • volatile修饰了flag变量,主线程改了flag的值,子线程可以感知到;

  • 如在上述代码的死循环中增加了System.out.println(), 则会强制同步flag的值,无论flag本身有没有加volatile

  • 如果volatile修饰一个引用对象,如果对象的属性(成员变量)发生了改变,volatile不能保证其他线程可以观察到该变化。

关于三级缓存

如上图,内存读出的数据会在 L3,L2,L1 上都存一份。

在从内存中读取数据的时候,根据的是程序局部性的原理,按块来读取,这样可以提高效率,充分发挥总线 CPU 针脚等一次性读取更多数据的能力。

所以这里引入了一个缓存行的概念,目前一个缓存行多用64个字节来表示。

如何来验证 CPU 读取缓存行这件事,我们可以通过一个示例来说明:

package git.snippets.juc;

/**
* 缓存行对齐
*
* @author <a href="mailto:410486047@qq.com">Grey</a>
* @since 1.8
*/
public class CacheLinePadding {
public static T[] arr = new T[2]; static {
arr[0] = new T();
arr[1] = new T();
} public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
}); Thread t2 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
}); final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
System.out.println("arr[0]=" + arr[0].x + " arr[1]=" + arr[1].x);
} private static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7;
} // T这个类extends Padding与否,会影响整个流程的执行时间,如果继承了,会减少执行时间,
// 因为继承Padding后,arr[0]和arr[1]一定不在同一个缓存行里面,所以不需要同步数据,速度就更快一些了。
private static class T /*extends Padding*/ {
public volatile long x = 0L;
}
}

代码说明

以上代码,T这个类继承Padding类与否,会影响整个流程的执行时间,如果继承了,会减少执行时间,因为继承Padding后,arr[0]arr[1]一定不在同一个缓存行里面,所以不需要同步数据,速度就更快一些了。

Java SE 1.8 增加了一个注解 @Contended,标注后就不会在同一缓存行, 但是这个注解仅适用于 Java SE 1.8,而且还需要增加 JVM 参数-XX:-RestrictContended

CPU 为每个缓存行标记四种状态(使用两位)

M: 被修改(Modified)

该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它 CPU 读取请主存中相应内存之前)写回(write back)主存。

当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)

该缓存行只被缓存在该 CPU 的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它 CPU 读取该内存时变成共享状态(shared)。

同样地,当 CPU 修改该缓存行中内容时,该状态可以变成Modified状态。

S: 共享的(Shared)

该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个 CPU 修改该缓存行中,其它 CPU 中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)

该缓存是无效的(可能有其它 CPU 修改了该缓存行)。

参考:【并发编程】MESI--CPU缓存一致性协议

有序性

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是:指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

我们分析一下下面这个代码的执行情况:

a = b + c;
d = e - f ;

先加载b、c(注意,既有可能先加载b,也有可能先加载c),但是在执行b + c的时候,需要等待 b、c 装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

为了减少这个停顿,我们可以先加载 e 和 f ,然后再去加载b + c,这样做对程序(串行)结果是没有影响的,但却减少了停顿:既然b + c需要停顿,那还不如去做一些有意义的事情。

综上所述,指令重排对于提高 CPU 处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为以下三种:

第一种:编译器优化重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

第二种:指令并行重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

第三种:内存系统重排

由于处理器使用缓存和读写缓存冲区,这使得加载( load )和存储( store )操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

乱序存在的条件是:不影响单线程的最终一致性( as - if - serial )

验证乱序执行的程序示例

package git.snippets.juc;

/**
* 并发编程的三大特性之:有序性
*
* @author <a href="mailto:410486047@qq.com">Grey</a>
* @since 1.8
*/
public class DisOrder {
private static int x = 0, y = 0;
private static int a = 0, b = 0; // 以下程序可能会执行比较长的时间
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
// 由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(100000);
a = 1;
x = b;
}); Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
// 出现这个分支,说明指令出现了重排
// 否则不可能 x和y同时都为0
System.err.println(result);
break;
} else {
// System.out.println(result);
}
}
} public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}

代码说明:

如上示例,如果指令不出现乱序,那么 x 和 y 不可能同时为 0,通过执行这个程序可以验证出来,在我本机测试的结果是:

执行到第 385634 次 出现了 x 和 y 同时为 0 的情况,说明出现了乱序。

原子性

程序的原子性是指整个程序中的所有操作,要么全部完成,要么全部失败,不可能滞留在中间某个环节;在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所打断。

一个示例:

class T {
m =9;
}

对象 T 在创建过程中,背后其实是包含了多条执行语句的,由于有 CPU 乱序执行的情况,所以极有可能会在初始化过程中生成以一个半初始化对象 t,这个 t 的 m 等于 0(还没有来得及做赋值操作)

所以,不要在某个类的构造方法中启动一个线程,这样会导致 this 对象逸出:因为这个类的对象可能还来不及执行初始化操作,就启动了一个线程,导致了异常情况。

volatile一方面可以保证线程数据之间的可见性,另外一方面,也可以防止类似这样的指令重排,所以,单例模式中,DCL方式的单例一定要加volatile修饰:

public class Singleton6 {
private volatile static Singleton6 INSTANCE; private Singleton6() {
} public static Singleton6 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton6.class) {
if (INSTANCE == null) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new Singleton6();
}
}
}
return INSTANCE;
}
}

具体可以参考设计模式学习笔记 中单例模式的说明。

说明

本文涉及到的所有代码和图例

图例

代码

更多内容见:Java 多线程

参考资料

实战Java高并发程序设计(第2版)

深入浅出Java多线程

多线程与高并发-马士兵

Java并发编程实战

【并发编程】MESI--CPU缓存一致性协议

【并发编程】细说并发编程的三大特性

设计模式学习笔记

Java 多线程:并发编程的三大特性的更多相关文章

  1. Java 多线程并发编程一览笔录

    Java 多线程并发编程一览笔录 知识体系图: 1.线程是什么? 线程是进程中独立运行的子任务. 2.创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run ...

  2. Java高并发编程基础三大利器之CountDownLatch

    引言 上一篇文章我们介绍了AQS的信号量Semaphore<Java高并发编程基础三大利器之Semaphore>,接下来应该轮到CountDownLatch了. 什么是CountDownL ...

  3. 【收藏】Java多线程/并发编程大合集

    (一).[Java并发编程]并发编程大合集-兰亭风雨    [Java并发编程]实现多线程的两种方法    [Java并发编程]线程的中断    [Java并发编程]正确挂起.恢复.终止线程    [ ...

  4. JAVA多线程-内存模型、三大特性、线程池

    一.线程的三大特性 原子性.可见性.有序性 1)原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行.原子性其实就是保证数据一致.线程安全一部分. 2)可见性,即 ...

  5. java多线程并发编程与CPU时钟分配小议

    我们先来研究下JAVA的多线程的并发编程和CPU时钟振荡的关系吧 老规矩,先科普 我们的操作系统在DOS以前都是单任务的 什么是单任务呢?就是一次只能做一件事 你复制文件的时候,就不能重命名了 那么现 ...

  6. Java多线程并发编程一览笔录

    线程是什么? 线程是进程中独立运行的子任务. 创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runnable 接口的类.该 ...

  7. Java 多线程并发编程面试笔录一览

    知识体系图: 1.线程是什么? 线程是进程中独立运行的子任务. 2.创建线程的方式 方式一:将类声明为 Thread 的子类.该子类应重写 Thread 类的 run 方法 方式二:声明实现 Runn ...

  8. Java基础系列篇:JAVA多线程 并发编程

    一:为什么要用多线程: 我相信所有的东西都是以实际使用价值而去学习的,没有实际价值的学习,学了没用,没用就不会学的好. 多线程也是一样,以前学习java并没有觉得多线程有多了不起,不用多线程我一样可以 ...

  9. Java 多线程并发编程

    导读 创作不易,禁止转载! 并发编程简介 发展历程 早起计算机,从头到尾执行一个程序,这样就严重造成资源的浪费.然后操作系统就出现了,计算机能运行多个程序,不同的程序在不同的单独的进程中运行,一个进程 ...

随机推荐

  1. 求求你们,别再刷 Star 了!这跟“爱国”没关系!

    这几年,随着几大互联网公司的强大,纷纷投入云计算产业的建设,开源项目作为维护潜在客户群体(开发者)的重要手段,是各大云计算厂商都在努力做的事. 这几年也诞生了很多真正优秀和看似优秀的开源项目.真正优秀 ...

  2. go-zero微服务实战系列(九、极致优化秒杀性能)

    上一篇文章中引入了消息队列对秒杀流量做削峰的处理,我们使用的是Kafka,看起来似乎工作的不错,但其实还是有很多隐患存在,如果这些隐患不优化处理掉,那么秒杀抢购活动开始后可能会出现消息堆积.消费延迟. ...

  3. CF1042E Vasya and Magic Matrix 题解

    题目链接 思路分析 看到题目中 \(n,m \leq 1000\) ,故直接考虑 \(O(n^2)\) 级别做法. 我们先把所有的点按照 \(val\) 值从小到大排序,这样的话二维问题变成序列问题. ...

  4. 数据孤岛下的新破局 Real Time DaaS:面向 AP+TP 业务的数据平台架构

    从传统数仓,到大数据平台,再到数据中台和湖仓一体新数据平台,在日益加重的数据孤岛困扰下,面向AP场景的解决方案可谓浩如烟海.但实际上,企业在TP类型业务上的投入和AP的比率却高达9:1,为什么没有为T ...

  5. myeclipse添加subclipse插件支持subversion1.9

    为了安装subclipse插件,费了很多周折,本来我以为直接用eclipse marketplace搜索安装就行,可是由于网络原因,安装不了. 然后下载安装包吧.目前从国内网站上下载不了支持subve ...

  6. SpringBoot接口 - 如何生成接口文档之非侵入方式(通过注释生成)Smart-Doc?

    通过Swagger系列可以快速生成API文档,但是这种API文档生成是需要在接口上添加注解等,这表明这是一种侵入式方式: 那么有没有非侵入式方式呢, 比如通过注释生成文档? 本文主要介绍非侵入式的方式 ...

  7. 关于API:好的设计和坏的设计【eolink翻译】

    以前开发或更新 API 时,我们经常需要深入讨论对 API 的结构.命名和功能等,这个花费了大量的时间. 随着 API 行业的蓬勃发展,API 设计也越来越重要.这么多年发展下来,一些如REST AP ...

  8. day06 Java_数组_方法_参数

    精华笔记: 数组: 复制: System.arraycopy(a,1,b,0,4); int[ ] b = Arrays.copyOf(a,6); 排序: Arrays.sort(arr); //升序 ...

  9. 什么新东西值得学「GitHub 热点速览 v.22.29」

    上周 18k+ 的项目 bun 这周又获得 7k+ star,是时候了解下它背后的编程语言 zig 了,它并不是一门新的语言,伴随着 bun 的风靡,zig 本周也上了 GitHub 热榜.同样,可以 ...

  10. 年中盘点 | 2022年,PaaS 再升级

    作者丨刘世民(Sammy Liu)全文共7741个字,预计阅读需要15分钟 过去十五年,是云计算从无到有突飞猛进的十五年.PaaS作为云计算的重要组成部分,在伴随着云计算高速发展的同时,在云计算产业链 ...