更多内容参见《并发与同步》系列
一、引子
二、JMM
三、Java中的线程
四、线程安全
五、锁优化
一、引子
运算能力
摩尔定律:晶体管数量,代表的CPU的频率
Amdahl定律:并行化与串行化的比重,代表的是多核心并行处理
物理机
物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
高速缓存:处理器和内存存取速度差异大,高速缓存做缓冲。
高速缓存的问题:缓存一致性;因为每个处理器都有自己的高速缓存,而它们又共享同一主内存,可能导致高速缓存中存储的同一块区域中的主内存的值不同;缓存一致性协议是为了解决这个问题。
内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象;不同架构的物理机内存模型不同,JVM也有自己的内存模型。
乱序执行优化:处理器只保证结果一致,不保证执行顺序;因此当多任务之间存在依赖时,不能依赖代码的执行顺序;类似于JIT中的指令重排序优化。
二、JMM
Java内存模型(JMM)
1、目标:屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。足够严谨,内存访问操作无歧义;足够宽松,JVM可以充分利用硬件特性优化速度。
2、JMM对访问作出规定的变量,包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数(线程私有)。
3、注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
主内存与工作内存
1、所有的变量都存储在主内存(与物理机中的主内存可类比,只是在虚拟机内存中)
2、每条线程有自己的工作内存(与物理机的高速缓存类比),工作内存中保存了被该线程使用到的变量的主内存副本拷贝(实现中大对象会有优化),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
3、不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
主内存/工作内存的划分与堆栈等的划分
1、不在一个层次上。
2、主内存对应Java堆中对象的实例数据部分【Java堆还保存了对象的其他信息,对于HotSpot虚拟机来讲,有MarkWord(存储对象哈希码、GC标志、GC年龄、同步锁等信息)、Klass Point(指向存储类型元数据的指针)及一些用于字节对齐补白的填充数据】。
3、工作内存对应虚拟机栈中的部分区域。
4、从硬件角度看,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中。
JMM的8种操作【原子的不可再分的(例外后面讲)】【现在不再这样描述,但是JMM并没有变】
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。
除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
1、不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
3、不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
4、一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
5、一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
6、如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
7、如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
这8种内存访问操作以及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。方便起见,后面将介绍一种等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。
volatile
volatile(long/double):见《并发系列》
volatile/synchronized与原子性、可见性、有序性:见《并发系列》
先行发生原则(主观判断比套用这些规则简单,是不是也行得通呢)
Java语言无须任何同步手段保障就能成立的先行发生规则包括:
1、程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
2、管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
3、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
4、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
5、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
7、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
8、传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
时间上先发生和先行发生互相推导不出对方;也就是说,时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准【一脸懵逼】。
三、Java中的线程
Java中线程的实现
1、统一接口
主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类的实例就代表了一个线程。
Thread类的所有关键方法都是native的,实现都是平台相关的。
2、实现线程的3种方式
1)使用内核线程实现(1:1线程模型)
内核线程(KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。程序一般不会直接使用内核线程,而是使用其高级接口——轻量级进程(LWP),LWP就是我们平时讲的线程。每个轻量级进程都由一个内核线程支持,这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。
缺点:各种线程操作(创建析构同步等)需要进行系统调用,在用户态和内核态之间来回切换,代价较高;1个轻量级进程对应1个内核线程,因此系统支持的轻量级进程数量有限。
2)使用用户线程实现
广义来说,一个线程只要不是内核线程就是用户线程,因此轻量级进程也属于用户线程。狭义来说,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。
优点:用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
缺点:所有的线程操作都需要用户程序自己处理,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题非常复杂且困难,极少程序使用这种方式;Java曾使用用户线程,最终放弃。
3)使用用户线程加轻量级进程混合实现
操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。用户线程代价小、数量多的优点同时得以保持。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系
3、Java使用了哪种方式
在JDK 1.2中,线程模型替换为基于操作系统原生线程模型来实现的;因此Windows版与Linux版中使用一对一的线程模型实现;solaris同时支持一对一和多对多的线程模型,因此可以通过参数选择。
Java的线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度和抢占式线程调度。
协同式:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。优点:实现简单;不存在同步问题。缺点:执行时间系统无法控制,程序容易阻塞。
抢占式:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。优缺点与上对应。Java使用抢占式。
建议(优先级、yield()等):不靠谱,具体见《并发与同步》系列
线程状态
线程从创建到最终的消亡,要经历若干个状态。一般来说,线程包括以下这几个状态:新建(new)、就绪(runnable)、运行(running)、阻塞(blocked)、限期等待(time waiting)、无限期等待(waiting)、消亡(dead)。有时说五种状态,是将就绪和运行合并为一个,并剔除消亡。
1、当需要新起一个线程来执行某个子任务时,就创建了一个线程。但是线程创建之后,不会立即进入就绪状态,因为线程的运行需要一些条件(比如内存资源,由于程序计数器、Java栈、本地方法栈都是线程私有的,所以需要为线程分配一定的内存空间),只有线程运行需要的所有条件满足了,才进入就绪状态。
2、当线程进入就绪状态后,不代表立刻就能获取CPU执行时间,也许此时CPU正在执行其他的事情,因此它要等待。当得到CPU执行时间之后,线程便真正进入运行状态。
3、线程在运行状态过程中,可能有多个原因导致当前线程不继续运行下去,比如用户主动让线程睡眠(睡眠一定的时间之后再重新执行)、用户主动让线程等待,或者被同步块给阻塞,此时就对应着多个状态:time waiting(睡眠或等待一定的事件)、waiting(等待被唤醒)、blocked(阻塞)。
注意:wait()是Object的方法,join()和sleep()是Thread的方法。除了wati()和join(),LockSupport.park()也可以让线程进入waiting();除了wait()/join()/sleep(),LockSupport.parkNanos()和LockSupport.parkUntil()也可以让线程进入time waiting()。
4、突然中断或者子任务执行完毕,线程就会被消亡。
下面这副图描述了线程从创建到消亡之间的状态:
四、线程安全
线程安全
简单定义:如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的。
详细定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
从线程安全角度对Java共享数据分类
1、不可变
基本数据类型:final即可
对象:final,而且对象本身也不会改变,如String、Integer、枚举、Number的部分子类等。实现对象不可变的方法有很多种,如所有变量final;或者全部private,且内部方法不改变状态(I)。
2、绝对线程安全
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。为了说明绝对安全,需要与相对安全对比。
例如,Vector类,add()/size()/get()/remove()等方法均有synchronized保护,是相对安全的类;但是在线程1中add()再get(),而线程2中remove(),可能导致线程1中抛出越界异常,因此不是绝对线程安全。
3、相对线程安全
即通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
4、线程兼容
指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
5、线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
典型例子:Thread类的suspend()和resume()方法;其他还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等(不懂)。
原因:如果要suspend的目标线程对一个重要的系统资源持有锁,那么没任何线程可以使用这个资源直到要suspend的目标线程被resumed。如果一条线程将去resume目标线程之前尝试持有这个重要的系统资源再去resume目标线程,这两条线程就相互死锁了。
因此,suspend()和resume()方法已经被JDK声明废弃了。
举例:例子中,被锁住的资源是System.out,它是一个static PrintStream。
public class Test { public static void main(String[] args) throws Throwable { Thread t0 = new Thread() { public void run() { for (long i = 0; i < 1000 * 1000 * 10; i++) { System.out.println(i); } System.out.println("thread death"); } }; t0.start(); Thread.sleep(10); t0.suspend(); System.out.println("主线程中打印"); t0.resume(); } }
线程安全的实现方法
1、如何编写代码
2、虚拟机如何实现同步与锁
互斥同步
1、互斥与同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。
互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。
2、synchronized:《并发与同步》
3、Lock:《并发与同步》
非阻塞同步
1、悲观的并发策略
互斥同步属于悲观的并发策略:总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行同步措施(理论模型,不考虑优化)。线程阻塞和唤醒会带来性能问题。
2、乐观的并发策略
非阻塞同步属于乐观的并发策略:先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)。
3、硬件指令集
非阻塞同步需要硬件指令集的支持:操作和冲突检测具备原子性;从硬件来说,就是多个操作可以通过一条指令完成 。这些指令包括:测试并设置、获取并增加、交换、比较并交换(CAS)、加载链接/条件存储;其中前3个在上个世纪的处理器中普遍存在;后两个是现代处理加入的,且功能类似。
CAS指令:CAS指令需要有3个操作数,分别是内存位置(V)、旧的预期值(A)和新值(B)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值。
Java中使用CAS指令:CAS操作由sun.misc.Unsafe里的compareAndSwapInt()等方法包装提供;但Unsafe类不直接提供给用户,因此如果不采用反射,只能由其他API间接调用,如AtomicInteger的increaseAndGet()(实现自增功能)方法。
经典实例:实现1在volatile中出现过。实现1的结果几乎一定小于10000,而实现2的结果一定是10000。
//实现1 public class Test { public volatile int inc = 0; public void increase() {inc++;} public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } } //实现2 public class Test { public AtomicInteger inc = new AtomicInteger(0); public void increase() { inc.incrementAndGet();} public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.inc); } }
实例中实现原理
public final int incrementAndGet(){ for(;){ int current=get(); int next=current+1; if(compareAndSet(current,next)) return next; }}
ABA问题:变量的值由A到B再改回A,CAS认为A没有变过,实际上变过了。大多数情况下,对并发没有影响;如果真的有影响,建议用synchronized。
无同步方案:无须同步方法就可以保证线程安全的代码
1、可重入代码:任意中断不会影响最终结果;特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入(?)、不调用非可重入的方法等。
2、ThreadLocal:见《并发与同步》
五、锁优化【为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率】
自旋锁与自适应自旋
1、原因:线程挂起和恢复在内核态中,用户态和内核态的切换性能差;经验表明,线程持有锁的时间往往很短。
2、自旋锁:如果有不止1个内核,便可以让等待的线程不挂起而是执行忙循环(自旋)。自旋次数默认为10次,可以更改。
3、自适应自旋:如果某个操作大概率自旋后获得锁,那么可以让循环次数多一点;小概率则可以取消自旋。
锁消除
1、原理:通过逃逸分析,发现某些带锁的数据无法线程逃逸(即不是共享数据),则可以消除锁。
2、写代码时不乱加锁不可以吗?程序中没有显式使用同步措施,但是使用的类/方法中使用了,或javac等优化后的代码使用了。如下例所示,优化后,append()方法中有同步快。
public String concatString(String s1,String s2,String s3){ return s1 + s2 + s3; } public String concatString(String s1,String s2,String s3){ StringBuffer sb=new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
锁粗化
如果代码中反复对同一个对象进行加锁解锁操作(典型例子如循环),性能较差;锁的范围可能粗化(扩展)到这些操作之外,变为一个加锁解锁操作。
轻量级锁
1、轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
经验依据:对于绝大部分的锁,在整个同步周期内都是不存在竞争的;因此如果竞争多,该优化反而可能导致性能变差。
2、对象头Mark Word的空间复用
3、轻量级锁:加锁
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的MarkWord的拷贝(Displaced Mark Word),这时候线程堆栈与对象头的状态如图13-3所示。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图13-4所示。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
4、轻量级锁:解锁
如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
偏向锁
1、如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
如果有竞争,性能比直接加锁还要低;JDK1.6以后默认开启,可以设置关闭。
2、原理
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(如果对象未锁定)或轻量级锁定(如果对象已锁定)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。