今天我们继续Deep Dive系列之80后回忆Concurrency.
作者写此文时正值端午佳节,下面60%的文章写于公海,韩国或者日本长崎,无wifi状态,特用大海照片作为封面作为纪念,看这深蓝色的大海,大家应该知道是哪里的海了,反正不是天朝。
大楼盖的高全靠地基牢, 今天我们继续Deep Dive系列之80后回忆Concurrency. 之前在老东家做过session, 今天再快速回顾一下吧.
我们先上一张J2SE的体系图,突然想到曾经有9(包括88)0(89)后,讲J2EE = SSH,让我无语, 我下次有空快速介绍一下j2se vs. j2ee体系吧。
Java经过近20年的发展, 已经包罗万象, 成熟稳定. Concurrency位于上图java and util:
1. Concurrent之父
老样子, 先看看Concurrent之父为何许人也. 对, 大神Doug Lea. 上图:
Doug Lea是何许人也?慈祥面善, 风度翩翩, 一看就是大学教授. Doug果然是纽约州立大学Oswego分校计算机科学的老大爷。作为对Java贡献最大的个人之一,直接或间接参与了Java历史上两次大变革。Java 1.2 推出的Collections框架承袭自Doug于95年发布的collections框架;以及大名鼎鼎的JDK 1.5推出的util.concurrent包。同时,Doug也是JCP一员。有图有真相:
推荐其大作:
2. Concurrent体系结构
这张图片不是那么美观, 却基本上涵盖了整个并发的架构:
PC内存结构
我们先来了解一下PC硬件结构,为啥? 因为作者是学数学的,没学过硬件,科普一下。哈哈,主要原因是稍后会有如何与JAVA内存结构对接。
如上图, 现代PC通常多核,每个CPU都包含一系列的寄存器,CPU在寄存器上执行操作的速度远大于在主存上执行的速度。每个CPU还有一个CPU缓存层,再与内存连接读取。
JVM内存结构
简单介绍一下HotSpot JVM的架构设计以及4大组件:
1.ClassLoader: Java类加载器,运行时动态加载class,并在加载时链接。包括了
2. Runtime Data Area:Java运行时,下面会详细介绍,主要有堆,栈,方法区,寄存器等。
3. Execution Engine:执行引擎,通过步骤1,2装载到运行时的java字节码都会被执行引擎执行;执行引擎通过两种方式把字节码转换成jvm可执行语言 1> 解释器 即我们说的解释执行 2> 即时编译器Just-In-Time, 整段字节码编译成本地代码,速度当然大幅提升。
执行引擎还包含著名的GC. 执行引擎是核心,是衡量sun还是ibm的jre好坏。
4. Native Method Interface:本地方法接口,调用JNI本地方法,不受jvm托管。
RUMTIME DATA AREA:运行时,简单介绍一下,关于jvm可以单独开一片deep dive了
JVM Stack:每个线程创建一个jvm堆栈,用来保存栈帧。jvm会在堆栈上对帧进行push/pop操作。当出现了异常,就会出现我们经常见到堆栈跟踪信息printStackTrace(); 而栈帧stack frame包含当前执行方法所属类的本地变量组,操纵数栈,以及运行时常量池引用。
Java中每个线程都有自己的线程栈,如上所述,线程栈包含了当前线程调用方法当前执行的相关信息。每个线程只能访问自己的线程栈,线程创建的本地变量对其它线程不可见。
值得提及的是,如果一个本地变量(即线程中的变量)是原始类型,则它会创建在线程栈上;但如过一个本地变量是指向一个对象的一个引用,则此时,这个本地变量引用会在线程栈,而起引用的对象本身则是放在堆上的(无论是哪个线程创建的)。当多个线程同时访问一个对象的成员变量时,每一个线程都会复制这个本地变量的私有版本。
下图是java内存与硬件内存架构之间的差异与桥接,对于硬件来说,所有的线程栈与堆都分布在主内存。从而引出了如下两个主要问题:
共享对象可见性:多个线程操作一个共享对象,一个线程的更新有可能对其它线程不可见。如,跑在cpu1的线程更新了一个共享对象;只要cpu缓存没有刷新回主存,则其更改对其它cpu线程不可见。从而引出了我们后即将介绍的volatie关键字。
4.并发核心体系
Race Conditions
多个线程同时更新某个共享对象或者资源,如果对资源的访问顺序敏感,就存在race conditions,而其导致race conditions的代码区就称之为临界区。同样,java提供了同步块来解决这个问题。同步块可以保证同一时刻,仅有一个线程可以进入代码的临界区以及保证代码块中所有被访问的变量都从主存中读入,当线程退出同步块后,所有被更新的变量会刷新回主存中。
Volatile
有了上面jmm的铺垫,我们可以简单介绍一下volatile关键字。当把一个共享变量声明为volatile后,相当于给其添加了同步的get/set方法对其进行保护,或者可以理解成使用同一个监视器锁对单个读/写操作做了同步。监视器锁通过happens-before规则保证释放监视器与获取监视器的两个线程之间的内存可见性。当写一个volatile变量时,jmm总是会把该线程本地内存中的共享变量刷新到主内存。即,对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入。
总结一下, volatile保证读写都是主内存变量;而我们熟悉的synchronized保证在块开始时同步主内存到线程工作内存,当块结束后将变量同步到主内存。
对应汇编源代码如下:
我们可以看到其本质是轻量级锁,而其主要功能就是防治指令重排。
CAS
Compare and Swap, 比较并交换的乐观锁。CAS大多数靠CPU指令来支持。首先CPU将内存中将要被更改的值读为A称之为久的预期值, 进行操作要修改的新值为B, 此时再读取内存值为V;当且仅当预期值 A与当前内存值V相同时,将内存值V修改为新值B, 否则返回V。
可以看出这是一种乐观锁思路,几本思路相信当前线程修改它之前,没有其它线程去修改;相反synchronized则为悲观锁,它直接加锁,不让其它线程进入。
如下图所示简单事例:
其核心compareAndSet由JNI借助cas指令完成:
CAS虽然高效,但其也存在三大问题:
ABA: 即一个值原来是a, 变成了b,后来又变成a。则使用CAS就无法检测出来。ABA问题的解决办法就是加上版本号变量每次更改都会增加版本如:A-B-A -> 1A-2B-3A.
自旋循环时间开销大。
只能保证一个共享变量的原子操作。
CAS说简单点就是“check then act”, 而其核心是整个“check then act”是原子的,所以需要cpu指令高效的来保证。
原子变量
下面我们接着介绍原子变量。 下图为java的原子变量包下面的类:
这些原子变量的几本特性就是在多线程并发环境下,当多个线程同时执行这些类的实例包含方法时,具有排它独占性,类似加锁。举个例子,i++;在普通不过的操作,自加1,而普通integer变量是包含以下步骤的:
获取当前i;
自加1;
把新值set到变量;
显然,以上为3步操作,多线程情况下无法保证其原子性。而Atomic*类正是大显身手的时候。
代码简单,明了,核心仍然是我们上文提到CAS理念的compareAndSet,其底层调用了cpu指令集,当然这里也采用了自旋乐观锁而非悲观独占锁。
自它原子变量大家自行领悟,自行参考网络资源。
AQS(AbstractQueuedSynchronizer)
如上图所述,java并发的lock甚至不夸张的说AQS为整个concurrent包的核心类之一,很多类如semaphore, countdownlatch都有一个内部类继承自AQS的子类Sync,
AQS整体主要做了以下事情:
线程阻塞队列的维护
线程阻塞与唤醒
至于AQS内部是如何实现阻塞队列的?看以下代码注释:
入队:
AQS整体内部代码2000+多行,还是有点复杂,需要些功力看懂,简单来讲,基于状态的链表来管理队列,队列当然是给那些没有拿到入场券的线程等待之处。
Thread State:
小伙伴说终于看到了一些人类生活区的字眼,thread state:
NEW, RUNNABLE, TERMINATED不讲了.
BLOCKED: 阻塞状态,等待锁。
WAITING: 一个已经拥有了对象锁的线程进入临界区后,主动调用锁对象的wait();或者类似的locksupport.park(); thread.join(); 通常是指在业务上需要某种协调等待之类。
TIMED_WAITING: 以时间为资源作为锁对象,如sleep(); 进行等待;
Lock
Java提供了轻量级的锁,lock如何轻量?我们知道synchronized是独占锁,当一个线程获取了该锁,并执行代码时,其它线程如上文提到处于等待blocked状态,直至获取锁的线程释放,即:
1)获取锁的线程运行完了该代码段。
2)执行中发生异常,jvm会让线程释放。
而其它大多数时候,线程们只好漫无目的的等待中。
另外当synchronized的线程仅仅是读操作时,其它线程也无法进行读操作,直至锁释放。而这些都可以用lock来实现,唯一不同的是,lock需要手工在try, finally里面来释放锁,否则可能会出现死锁现象。
lock() : 获取锁,如锁已经被其它线程获取,则等待。
tryLock(): 获取锁,有返回值true/false, 拿不到锁不会一直等待。
tryLock(long time): 与tryLock类似,可选时间,在制定时间拿不到锁就返回。
unLock():释放锁,需要手工在try, finally里面来释放锁,否则可能会出现死锁现象。
ReentrantLock
可重入锁,如果锁具备可重入性,则称作可重入锁。如线程a执行某个synchroized方法method1,在method1中调用另外一个synchroized方法method2,此时线程a步需要重新申请锁,而是可以直接访问method2. synchroized与lock都具备可重入性。
ReentrantLock实现了lock接口。ReentrantReadWriteLock 则提供了更精致的readLock(), writeLock()控制到读锁与写锁粒度。
并发容器
java提供了并发容器来支持并发,如我们熟悉的ConcurrentHashMap, CopyOnWriteArrayList等,其主要解决问题包括
细化锁机制,同步容器如HashMap直接把整个容器作为锁,这样太悲观,浪费资源。并发容器则采用更细粒度的锁,分离锁。
附加原子性复合操作,如concurrenthashmap.putIfAbsent();
ConcurrentHashMap
太著名了吧,分离锁的魅力, 默认16段segment。
CopyOnWriteArray*
在写入时复制的方式,即通过冗余与不可变性来解决并发。适用于写入远远小于迭代/读操作的时候。
Queue
队列,标准的FIFO数据结构。包含了阻塞队列,BlockingQueue, 提供了可阻塞的put于take,类似我们的生产者/消费者模式。如果队列已经满了,则put会一直阻塞直至有空间可用;同理,如果队列是空的,则take也会一直阻塞到有元素可用。阻塞队列又根据需要分为多种实现,如ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue, DelayQueue等。
简单来说,阻塞队列就是当队列满的时候写线程等待直至有空缺;当队列空的时候,读线程等待直至不空。
并发执行框架
好了,来点小伙伴常用的并发框架吧。
Executor: 简单接口,只提供execute方法,但其为整个并发框架的基石。
ExecutorService: 扩展接口, 提供了submit,invoke方法等更为丰富的扩展。
Executors: 工厂类,提供了newFixedThreadPool, newSingleThreadExecutor, newCachedThreadPool, newScheduleThreadPool等,见名知意。
ExecutorCompletionService:将执行完的任务结果放到阻塞队列中。如果任务还没有完成则当前线程会阻塞,如果我们希望任意任务完成后就把其结果加到result中,而不用依次等待每个任务完成,可以使用CompletionService。
同步器:
同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作。
CountDownLatch:倒计数器 锁存器是一次性障碍,允许一个或者多个线程等待一个或者多个其它线程来做某些事情。
时间关系,其它不多讲了,大同小异。
公众号:技术极客TechBooster