图解java并发(上)

为什么要“并发”?

既然聊并发,我们首先会思考为什么要引入这个技术。通常写程序,我们习惯用单线程串行的思维理解程序运行,并写业务逻辑。这样可以减少复杂度,也便于测试,往往当需要性能提升,我们才会想到使用并发。那么这个技术到底能够给我们带来什么呢。

充分利用cpu资源

多核处理器的广泛使用背景下,如果我们的程序还是单线程串行的运行,会对硬件资源浪费。比如有一个5内核的cpu,单线程对cpu的损耗不会超过1/5。这对硬件的使用明显是中巨大浪费。

图解java并发(上)
只有一半的cpu资源得到了利用。

更快

比如用户在手机上下了一个贷款申请,它包括插入申请数据,社会审核、金融信誉审核、其他审核、发送邮件通知,生成分期账单等等。用户贷款申请,需要这些流程都完成,才能保证贷款申请流程完毕。如何能让这些流程更快执行呢?可以使用并发,对数据弱一致性的业务并行处或者异步处理,缩短响应时间 ,提升用户体验。

并发的风险

我们都知道,线程在java中作为最小的执行单元,在java中我们通过Thread类去抽象每个线程个体。并发就是让多个线程同时执行,每个线程作为一个独立的个体去完成逻辑执行。

上边说了我们使用并发技术的动机,每个硬币都有两面,并发技术也不例外,再给我们带来益处的同时,也存在一些风险需要去谨慎注意。

性能损耗

  • 创建线程
    每个线程的创建需要堆栈资源,也需要占用操作系统中一些资源来管理线程。即使线程什么都不做的情况下。
  • 上下文切换多线程运行中,cpu会给每个线程分配时间片,也就是轮流占用cpu。这样会产生上线文切换——也就是保留当前线程状态,切换到下一个线程,下一个线程加载上次的状态,继续运行——从保存当下状态到下次再加载的过程就是上下文切换。

只有一半的cpu资源得到了利用。
上下文切换示意图

更加复杂,有挑战

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

理解并发

从整体上来讲,理解并发就是要理解多线程之间的通信与同步。
通信

java 中通过共享内存实现通信,但也不局限与内存,也可以是任何共享的存储数据。通信的同义词有握手、交互,一个意思。

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

图解java并发(上)

线程A会更新某个变量,然后线程A将这个更新的变量刷入主存中去。线程B会到主存中获取这个线程A更新过的共享变量。这两个步骤就完成了一次通信

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

JMM

JMM(Java memory model )描述了线程之间如何通过内存实现通信。
图解java并发(上)

同步

 线程同步指的是多个线程相互排序执行以及在某些特定时间进行握手,以完成一个共同的目标或者执行一系列有序的动作。
  • 1

图解java并发(上)

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

图解java并发(上)

在java中通过使用 volatile关键字(无锁实现同步)、Lock、synchronized关键字、原子类等手段来完成同步,以解决因为同步产生的竞争状态。
哲学家进餐问题、读写者问题,生产消费者问题都是同步的经典问题,为了加深理解,读者应该尝试写一下。
接下来,我们来详细看看java中的同步手段。

volatile

可见性

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

一个线程对变量更新,另外一个线程是否可以看见这个更新了的值.一个实例,主线程更新了标识符,另外一个线程始终没有及时看到。

示例代码:

		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的最新数据,一直处于运行状态。

图解java并发(上)

为什么不可见

计算机为了提高整体运行效率,使得CPU不会直接与内存(主存)进行通信,会先使用缓存替代主存。
使用缓存好处主要两点:一,缓存读写数据比内存读写数据速度更快,能更好地被CPU使用。二,如果缓存可以部分满足CPU对主存的需要,那么就会降低主存的读写频率,意味着降低总线的繁忙程度,整体上提高机器的执行速度。

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

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

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

static boolean isRunning = true;

改为

static volatile boolean isRunning = true;

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

除了缓存会影响可见性,重排序也会影响可见性(因为代码执行顺序打乱),下文详述重排序问题。

volatile 到底做了什么

  • 有volatile变量修饰的共享变量进行写操作的时候会使用lock汇编指令,而lock指令(默认场景为多核处理器下)会引发了三件事情:
    将当前处理器缓存行的数据会写回到系统主存。
    写回主存操作会接着使其他存储了这个变量的缓存数据失效(缓存一致性协议保证)。
    禁止某些指令的重排序(或者说建立关于volatile的happen-before规则:对volatile的写操作必须对之后的这个变量的读操作可见)

在一个volatile变量的写操作中,JVM会同时向操作系统发送lock指令(volatile的关键点),这会导致这个变量对应的缓存被原子性的写入到主存中。

光是写入主存这个操作还不够,因为其他线程下次从其他任何存储了这个数据的缓存中读取这个变量,也是错误的。

因此,会使其他地方缓存了这个数据的缓存失效,下次就会直接从主从中读取。

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

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

重排序

什么是重排序

在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允许编译器、JIT、CPU等硬件自由的改变这三个操作的顺序。

在单线程情况下,我们感觉不到代码(以及代码对应的汇编指令)的重排序,这是因为JMM的约束:在单线程下,compiler、JIT、CPU可以任意的重排序,但是前提是不影响代码执行结果。也就是我们主管感觉的顺序执行(“as-if-serial”)。
但是,在未正确同步的多线程代码中,这种重排序经常造成“非预期的结果”。

对策总比问题多,JMM中通过定义一些关键字的语义,禁止了某些重排序( a partial ordering ),实际上就是通过使用”内存屏障”的方式来禁止某些不受欢迎的重排序,使得程序按照我们的预期正确同步并执行。

happen-before

( a partial ordering )部分禁止重排序,也可以理解为限定好某些操作执行的先后顺序,不允许其改变,换句话说也就是对这些操作做了同步处理。

这个因禁止某些重排序而保留下来的特定的先后顺序称为happen-before规则。

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

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

Double-check locking

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

/*
 *  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:分配内存空间

操作2:初始化Helper对象

操作3:将helper引用指向内存空间

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

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

        private volatile Helper helper = null;

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

操作1:分配内存空间

操作2:初始化Helper对象

操作3:将helper引用指向内存空间(写操作,禁止之前的操作重排序到这个写操作之后)。

这里写图片描述
synchronized

互斥执行(mutual exclusion)

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

// Java code:
Synchronized (this) {  
//stuff 
}   
 
//bytecode
Public some Method()V ALOAD 0 DUP MONITORENTER MONITOREXIT RETURN 

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

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

synchronized的“可见性”

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

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

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

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

总结

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

开头先对比了java并发技术的优劣以及挑战,然后从线程间通信与线程间同步这两个本质的问题切入,详细描述了什么是线程间通信、什么是线程间同步,并因此引入了JMM这个模型概念。

从JMM的讲解继续深入,引出了可见性问题、重排序问题、happen-before。并用经典的double-check locking问题作为实例以加强理解。最后,关于JMM中的语义细节,用底层的实现原理讲解了java语言层面的两个重要关键字volatile、synchronized。

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

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/tech/pnotes/60814.html

(0)
上一篇 2021年8月10日 18:49
下一篇 2021年8月10日 18:49

相关推荐

发表回复

登录后才能评论