干货 | 聊聊java并发(上)

(一)为什么要“并发”?

既然聊并发,我们首先会思考为什么要引入这个技术。通常写程序,我们习惯用单线程串行的思维理解程序运行, 编写业务逻辑(实际上我们通常的代码并不是按顺序串行执行的,只是看上去像,as-if-serial)。 这样可以减少复杂度,也便于测试,往往当需要性能提升,我们才会想到使用并发。那么为什么要并发呢?

接下来,我尝试系统、深入、图文并茂的聊一下并发、JSR133中定义的JMM以及其中具体的语义细节。

1.充分利用CPU资源

首先,并发可以充分利用CPU资源。简单来说就是多核处理器的广泛使用背景下,如果我们的程序还是单线程串行的运行,会对硬件资源浪费。 比如有一个8内核的CPU,单线程对CPU的损耗不会超过1/8。这对硬件的使用明显是巨大浪费。

一个监控示例:只有一半的CPU资源得到了利用

更重要的是,(目前)CPU的处理运行速度比(磁盘读写、网络IO等)通信、存储子系统快几个数量级,我们单纯地串行处理任务,CPU的速度即使再快,也会因为其他子系统的速度过慢,而不得不较长时间“陪同”(如,大量时间花在IO上),浪费CPU资源。多线程可以针对这种情况,更大程度的利用因通信、存储子系统过慢而闲置的CPU资源。

也就是说我们不能指望程序自发的充分利用CPU资源,我们完全可以在CPU被空闲时(如,时间全部花在IO上)充分利用它,去做更多的事情。

设计了一个小程序,模拟上文所说内容:

/**
 * @author zhangsh
 *
 *  充分利用CPU,单位时间内执行更多任务
 */
public class MakeFullUseOfCPU {
 public static void main(String[] args) throws Exception {
 new Thread(oneIOTask(), "[IO bottleneck task]").start();// 瓶颈在IO的一个任务,下图可以看到时间完全卡在IO上
 /**
        *  cpu处理速度比IO系统处理速度快几个数量级,并发编程充分利用CPU,单位时间内执行更多任务
        */
 new Thread(oneTaskToUseCPU(), "[other task1]  (make full use of CPU)").start();
 new Thread(oneTaskToUseCPU(), "[other task2]  (make full use of CPU)").start();
 new Thread(oneTaskToUseCPU(), "[other task...]  (make full use of CPU)").start();
 }
 static Runnable oneIOTask() {
 returnnew Runnable() {
 @Override
 public void run() {
 try {
 System.in.read();// IO任务模拟
 } catch (IOException e) {
 e.printStackTrace();
 }
 }
 };
 }
}

2.更快、更好的体验

比如用户在手机上下单去贷款申请,它包括插入申请数据、个人信息审核、金融信誉审核、其他审核、发送邮件通知,生成分期账单等等。用户贷款申请,需要这些流程都完成,才能保证贷款申请流程完毕。如何能让这些流程更快执行呢?答案很多,但并发肯定是最有效的一个。使用并发技术、对数据弱一致性的业务并行处理或者异步处理,缩短响应时间 ,提升用户体验。下图生动地对比了并行与串行的任务执行模式:

(图片来源于:http://mesatheory.com/multitasking%20serial%20vs%20concurrent.jpg)

(二)并发的风险

我们都知道,线程在Java中作为最小的独立执行单元,在Java中我们通过Thread类去抽象每个线程个体。并发就是让多个线程同时执行,每个线程作为一个独立的执行单元去完成逻辑执行。上边说了我们使用并发技术的动机,每个硬币都有两面,并发技术也不例外,在给我们带来益处的同时,也存在一些风险需要去谨慎注意:

1.性能损耗

  • 创建线程

每个线程的创建需要堆栈资源,也需要占用操作系统中一些资源(cpumemory...)来管理线。

举例来说,之前遇到过的垂直架构—tomcat部署的纯计算应用(OTA行业的价格计算中心,每天计算量达到100亿次), 不对外提供任何http服务。但tomcat默认启动的http数百个线程(http线程池)就是一种性能浪费。

如图:

(此图片并非生产问题原图,为了说明具体情况而模拟。图片包含内容较多,请放大查看。)

  • 上下文切换

多线程运行中,cpu会给每个线程分配时间片,也就是轮流占用cpu。这样会产生了上线文切换——也就是保留当前线程状态,切换到下一个线程,下一个线程加载上次的状态,继续运行——从保存当下状态到下次再加载的过程就是上下文切换。

上下文切换也会对系统造成额外的消耗,主要原因就是每次上下文切换都需要额外的时间去"恢复现场"与"保存现场"。

(上下文切换示意图)

2.更复杂,有挑战

并发编程比串行的编程更加复杂,要考虑锁问题、线程安全、重排序问题、共享数据的一致性、线程池的设置等等。

(三)理解并发

简单来说,理解并发就是要理解多线程之间的通信与同步

1.线程间通信

所谓线程间通信就是线程间告知\接收彼此直接的信息,也可以叫做握手、交互。

Java中可以通过共享内存实现通信,但也不局限于内存,也可以是可共享的任何数据。

举例来说: 比如,线程A需要让线程B修改某些属性然后去执行,那么线程A该如何告诉线程B自己的需求呢?

更具体地说:

线程A-action:更新某个变量,然后将这个更新的变量刷入主存中去。

线程B- action:会到主存中获取这个线程A更新过的共享变量。

这两个步骤就完成了一次通信。

实质就是线程A向线程B发送了包含更新数据的消息,这种通过共享主存的通信方式是隐式的通信,还有消息传递的并发模型通过直接发送消息通信。

2.线程间同步

多线程正确的同步❶指的是多个线程有序的执行线程任务,以完成一个共同的目标或者执行一系列有序的动作。

(❶注释:有些文章用词为——“错误地同步”(incorrectly synchronized)、未完成地同步(incompletely synchronized)或没有同步(without any synchronization、 no synchronization),这些词含义都是一样的,所引起的问题也都是一样的。所以本文统一用词为“正确的同步”或者“错误的同步”,以避免读者困扰。)

确保三个线程按照一定顺序操作共享资源

(对共享资源操作的代码区称为临界区)

换句话说,正确的同步就是保证按照正确的顺序让线程运行并完成通信,类似现实生活中的红绿灯,如果没有红绿灯,后果可想而知。

在Java中通过使用volatile关键字、Locksynchronized关键字、原子类等手段来完成同步(以及通信,线程间通信与同步关系密切),以解决因为同步产生的竞争状态。哲学家进餐问题、读写者问题,生产消费者问题都是同步的经典问题,为了加深理解,值得一读。

  • 什么是错误的同步

当我们在Java并发编程中谈到“错误同步”的代码,我们是指这样的一些代码:

  • 线程A执行一个变量的写操作;
  • 线程B执行这个变量的读操作;
  • 这两个操作没有被正确的同步处理,也就是说他们执行的先后顺序是不确定的。
  • 当出现了以上类似情况我们通常称在这个变量上存在“数据竞争”(a data race)。一个存在数据竞争的程序都是没有正确同步过的程序,存在隐患。

3.JMM

JMMJava memory model,通常说的是在JSR133中确定的JMM )定义了什么样的行为在Java多线程中合法。详细规范了线程之间如何通过内存实现通信,通信的具体细节;还有程序中变量之间的关联,以及这些变量如何在寄存器、缓存、主存上被读\写的细节。(注意区别JVM内存结构)

简单来说,通过一些并发细节的规范、约束,对线程之间的通信规则做了完整、详细的描述,并且屏蔽了操作系统以及硬件的底层细节,抽象出一个跨平台的内存模型,以便程序员可以更高效、准确的使用JAVA进行并发编程。这个模型内存就是我们常说的JMM

(JMM)

图中的WorkingMemory是代指内存模型中的抽象概念,涵盖缓存、缓冲区、寄存器、编译器优化器等硬件设备。主存就是共享内存包括HeapMethod Area

Java中允许程序员使用一些关键字(如volatile, synchronized和final)向编译器提出并发处理的需求(这些关键字的行为已经在JMM中明确),来确保线程之间能够及时通信并且能够正确同步,最终完成并发程序。

接下来我们就通过深入理解一下这些关键字,来充分地理解JMM

(四)volatile

1.可见性

说到volatile就要从可见性问题说起,那什么是可见性呢?

一个线程对变量更新,另外一个线程是否可以看见这个更新了的值。一个实例,主线程更新了标识符,另外一个线程始终没有及时看到。我们来手动模拟一下:

/**
 * @author zhangsh
 *
 *  可见性问题 模拟演示
 */
public class NoVisibility {
 static boolean isRunning = true;
 public static void main(String[] args) throws InterruptedException {
 Thread runningT = getRunningThread();
 runningT.start();
 TimeUnit.SECONDS.sleep(10);
 isRunning = false;//注意: main Thread 执行到此,预期runningThread 应该结束
 }
 public static Thread getRunningThread() {
 return new Thread(new Runnable() {
 @Override
 public void run() {
 while (isRunning) {
 }
 }
 },"RunningThread");
 }
 }

运行后线程监控,发现RunningThread这个线程始终无法读取到isRunning=false执行后的最新数据,RunningThread一直处于运行状态。如图:

  • 为什么不可见

计算机为了提高整体运行效率,使得CPU不会直接与内存(主存)进行通信,会先使用缓存替代主存。

使用缓存好处主要两点:一,缓存读写数据比内存读写数据速度更快,能更好地被CPU使用。二,如果缓存可以部分满足CPU对主存的需要,那么就会降低主存的读写频率,意味着降低总线的繁忙程度,整体上提高机器的执行速度。

缓存有优点,但是同样也会带来一些问题:因为线程之间通过主存(就是常说的内存,下文统一称为“主存”)通信,主存是可以被多个CPU共享访问的,而缓存只能供当前的CPU访问,关键是一个缓存与主存同步数据的频率是没有严格约束的,那么也就是说CPU之间无法及时看到彼此最新更新的数据(因为可能某些数据还没有同步到主存)。

回顾JMM结构图,WorkingMemory包含此处说的缓存之外,还包含寄存器、编译器等。

WorkingMemory不能再线程之间共享,类比于CPU不能在缓存中共享,实际上JMM范围更大,抽象程度更高。因此在上边的程序中,如果对一个变量(非volatile)进行写操作,会首先写入workingMemory,"稍后"会更新到主内存。但是具体是什么时候更新到主存去就很不确定了,这就导致了其他线程会出现数据(最新值)不可见的情况。

接着说上边代码的例子,当我们将

static boolean isRunning = true;

改为

static volatile boolean isRunning = true;

使用volatile修饰,问题就解决了,可以自行尝试下。

2.volatile到底做了什么

volatile变量修饰的共享变量进行写操作的时候会使用lock汇编指令,而lock指令(默认场景为多核处理器下)会引发了三件事情:

  • 将当前处理器缓存行的数据会写回到系统主存。
  • 写回主存操作会接着使其他存储了这个变量的缓存数据失效(缓存一致性协议保证)。
  • 禁止某些指令的重排序(或者说建立关于volatile的happen-before规则:对volatile的写操作必须对之后的这个变量的读操作可见)

在一个volatile变量的写操作中,JVM会同时向操作系统发送lock指令(volatile的关键点),这会导致这个变量对应的缓存被原子性的写入到主存中。光是写入主存这个操作还不够,因为其他线程下次从其他任何存储了这个数据的缓存中读取这个变量,也是错误的。因此,会使其他地方缓存了这个数据的缓存失效,下次就会直接从主从中读取。

简单来说,volatile在操作系统层面保证了变量单个操作(读或写)的原子性、可见性。另外需要注意:volatile i++并非是单个操作,所以并不能原子性完成。(lock指令的更多细节不做展开。)

前文中说道,lock指令会禁止重排序,那么我们通过对volatile的理解来聊一下“重排序”这个问题。

3.重排序

  • 什么是重排序?

JMM中,编译器(包括JIT)、CPU、缓存被允许做一些代码指令的重新排序以达到优化性能的目的。

比如:

public class ReorderDescribe {
 static int a = 0;
 static int b = 0;
 static int c = 0;
public static void main(String[] args) {
 a = 1;// 操作1
 b = 2;// 操作2
 c = 3;// 操作3
 }
}

从代码中来看,执行顺序“应该是”操作1——>操作2——>操作3,但是JMM允许编译器、JITcpu等硬件自由的改变这三个操作的顺序。 在单线程情况下,我们感觉不到代码(以及代码对应的汇编指令)的重排序,这是因为JMM的约束:在单线程下,compilerJITcpu可以任意的重排序,但是前提是不影响代码执行结果。也就是我们主管感觉的顺序执行("as-if-serial")。

但是,在未正确同步的多线程代码中,这种重排序经常造成“非预期的结果"。对策总比问题多,JMM中通过定义一些关键字的语义,禁止了某些重排序a partial ordering),实际上就是通过使用"内存屏障"的方式来禁止某些不受欢迎的重排序,使得程序按照我们的预期正确同步并执行。

4.happen-before

a partial ordering )部分禁止重排序,也可以理解为限定好某些操作执行的先后顺序,不允许其改变,换句话说也就是对这些操作做了同步处理。这个因禁止某些重排序而保留下来的特定的先后顺序称为happen-before规则。

如果说A happen before B,那么就保证A会在B之前执行,并且A操作对B可见。

具体的happen-before规则如下

  • 同一个线程中的操作,都是按照代码编写的顺序执行(从执行结果的角度来看)。
  • 一个对象锁的释放 一定会发生在 这个锁随后被获取的操作之前(同一个锁先要被释放,才能接着获取到)。
  • 对一个volatile变量的写操作 一定会发生在 随后的这个volatile变量的读操作之前(不允许把volatile写操作之后的代码重排序到它之前;并且volatile写操作立即可见)。
  • 对一个线程的start()方法的调用一定会发生在 这个线程被启动后执行的任何动作之前
  • 一个线程中的所有操作 一定会发生在 其他线程成功的从这个线程的join方法返回之前

5.double-check locking

底层的内存屏障对于java语言的使用者来说,主要就是volatile关键字、锁。我们接下来通过一个经典的例子来具体分析一下重排序问题。

以下是一种典型的doubleCheck错误:

/*
 * Broken multithreaded version
 */
class Foo {
 private Helper helper = null;
 public Helper getHelper() {
 if (helper == null) {
 synchronized (this) {
 if (helper == null) {
 helper = new Helper();
 }
 }
 }
 return helper;
 }
 // other functions and members...
}

为什么会错误呢?看了又看都没发现错误在哪里。 其实这里的错误会出现在helper=new Helper中,因为这句代码并不是原子操作,实际上分为三个操作,并且三个操作允许被重排序。

但是从单线程来看,这三个操作如果是这样的顺序:1——>3——>2,也并不会被我们感知到,也就是说满足as-if-serial的"顺序执行"要求。但是在为正确同步的多线程中,就会发生问题(如图):

使用volatile禁止重排序,正确同步线程。只要在声明helper引用时使用volatile修饰即可正确同步代码。

private volatile Helper helper = null;

volatile禁止写操作之前的任何操作被重排序后边,所以我们得到的结果就是:

bugFixed!

(五)synchronized

1.互斥执行(mutual exclusion)

Synchronized最为人熟知的就是“互斥执行”特点。我们先看看synchronized块的字节码:

// Java code:
synchronized (this) { 
//stuff
} 
//byteCode
public someMethod()V ALOAD 0 DUP MONITORENTER MONITOREXIT RETURN

synchronized使用monitor机制(monitorenter/monitorexit),通过获取获取\释放同一个对象锁来完成临界区(也称为同步块)互斥执行。

同一时刻只有一个线程可以获得一个monitor(可以理解为对象锁,获得锁对应指令为monitorenter),所以在这个monitor上的阻塞的代码块只允许获得这个monitor的线程进入执行,其他线程都无法获得这个monitor,当然也无法进入同步块,必须等到当前同步块中的线程退出同步块,并释放这个monitor后才可尝试进入。

除“互斥执行”作用,synchronized还具备保证可见性的作用。

2.Synchronized的“可见性”

Synchronized确保了一个线程在进入同步块中(或进入同步块之前)的写操作对其他线程立即可见。

在一个线程进入synchronized 块之前,首先要获取对象锁(执行monitorenter)。这个线程获取对象锁成功的同时,会使得当前CPU缓存的数据失效,那么接下来的读操作,就会重新从系统主存中读取(并填充缓存)

当一个线程在退出synchronized同步块时,释放对象锁(执行monitorexit),同时会保证当前线程的缓存数据被刷入主内存,所以这个线程在退出同步块之前的写操作对其他线程可见。

可以看到同一个monitor对象锁的释放和获取都会导致缓存数据刷入主存、缓存数据被重新从主存更新,那么缓存数据都会被即使更新并同步主存,很明显消除了可见性问题。

总结

本文试图用图文并茂、实例模拟等方式向大家阐述并发的核心难点。

开头先对比了java并发技术的优劣以及挑战,然后从线程间通信与线程间同步这两个最本质的问题切入,详细描述了什么是线程间通信、什么是线程间同步,并因此引入了JMM这个模型概念。从JMM的讲解继续深入,引出了可见性问题、重排序问题、happen-before。并用经典的double-check locking问题作为实例以加强理解。最后,关于JMM中的语义细节,用底层的实现原理讲解了java语言层面的两个重要关键字volatilesynchronized

下文我们将着重理解JDK中的并发组件的实现原理、分布式相关问题、还原曾遇到过的高并发系统的线上问题,以及目前业界对并发系统的一些处理手段等等。

原文发布于微信公众号 - vivo互联网技术(vivoVMIC)

原文发表时间:2018-03-22

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区