第三部分:高效并发
一、Java内存模型与线程
1.概述
这里我们会介绍虚拟机如何实现多线程、多线程之间由于共享和竞争数据而导致的问题以及解决方案。
2.硬件的效率与一致性
3.Java内存模型
定义java内存模型并非一件容易的事情,模型必须定义得足够严谨,但是同时定义的又必须足够宽松,使得虚拟机的实现能有足够的自由空间区利用硬件的各种特性来获取更好的执行速度。
3.1 主内存与工作内存
java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作,都必须在工作内存中运行,而不馁直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
3.2 内存间交互操作
java内存模型定义了以下八种操作:
- lock 锁定:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock 解锁:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read 读取:作用于主内存中的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use 使用
- assign 赋值
- store 存储
- write 写入
- load 载入
3.3 对于volatile型变量的特殊规则
关键字volatile可以说时java虚拟机提供的最轻量级的同步机制了,但是它并不容易被正确的、完整的理解。
java内存模型对volatile专门定义了一些特殊的访问规则,在介绍这些比较拗口的规则定义之前,先来使用通俗易懂的语言来介绍以下这个关键字的作用。
- 当一个变量被定义为volatile之后,他将具备两种特性,第一是保证此变量对所有线程的可见性。这里的可见性就是当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。其实在各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,所以执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。
- 使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量复制操作的顺序与程序代码执行的顺序一致,因为在一个线程的方法执行过程中无法感知到这点。
3.4 对于long和double型变量的特殊规则
3.5 原子性、可见性、有序性
-
原子性:java内存模型来直接保证的原子性变量操作包括read load assign use store write六个,我们大致可以认为基本数据类型的访问读写是具备原子性的。
如果应用场景需要一个更大范围的原子性保证,java内存模型还提供了lock和unlock操作来满足这种需求。
-
可见性:就是当一个线程修改了共享变量的值,其他线程能狗立刻得知这个修改。除了volatile可以实现可见性以外,synchronized和final也可以实现。同步块的可见性是指对一个操作执行unlock操作之前,必须先把此变量同步回主存中。而final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么其他线程就能看见final字段的值。
-
有序性:java天然的有序性,就是指在本线程中观察那么所有的操作都是有序的,但是如果在一个线程中观察另一个线程,所有的操作都是无序的。
3.6 先行发生原则
4.Java与线程
4.1 线程的实现
我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件IO),又可以独立调度(线程是CPU调度的最小单位)。
实现线程主要有三种方式:
- 使用内核线程实现
- 使用用户线程实现
- 混合实现
4.2 Java线程调度
一般来说线程调度有两种方式,一种是协同式,一种是抢占式,java中使用的是抢占式。
4.3 状态转换
java语言定义了五种线程状态,在任意一个时间点中,一个进程只能有且只有一种装固态,这五种状态分别是:
- 新建 new : 创建后还没启动的线程就是这种状态
- 运行 runable : 包括了操作系统线程的running和ready ,也就是此状态的线程可以正在执行,也有可能正在等待这CPU为它分配执行时间
- 无限期等待 waiting : 处于这种状态的进程不会被分配CPu执行时间,它们要等待被其它线程显式地唤醒。以下几个方法会让线程陷入waiting中
- Thread.sleep()
- Object.wait()
- Thread.join()
- LockSupport.parkNanos()
- LockSupport.parkUntil()
- 阻塞 Blocked :进程被阻塞了,阻塞和等待的区别是:阻塞在等待着获取到一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生;而等待则是在等待一段时间,或者唤醒事件的发生,在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束 Terminated : 已终止的线程的线程状态,线程已经结束执行。
二、线程安全与锁优化
1.概述
我们在编程的同时,必须让程序在计算机中正确无误的运行,然后再考虑如何将代码组织的更好,让程序运行的更快。对于并发来讲,我们先要保证并发的正确性,然后实现高效。
2.线程安全
如果一个对象可以安全地被多个线程同时使用,那么它就是线程安全的。或者说,当多个线程访问一个对象时,如果不用考虑这些线程再运行时环境下的调度和交替运行,也不需要进行额外的同步,或者再调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
2.1 Java语言中的线程安全
java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对相称安全、线程兼容和线程对立。
2.1.1不可变
在java语言中,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不用在进行任何的线程安全保障措施。在java中被final修饰的东西就是不可变的,string类的对象也是一个典型的不可变对象。
2.1.2 绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
我们需要注意,Vector在我们平常看来,他一定是线程安全的,因为的各个方法都被synchronized修饰的,尽管这样效率低但是安全,但是我们需要知道,在多线程的情况下,这个类的方法调用还是会出现问题,所以他也不是绝对线程安全的。
2.1.3 相对线程安全
相对线程安全就是我们常说的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施。但是对一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用到的正确性。
java语言中,大部分线程安全的类都是属于这种类型,如Vector,hashtable 以及Collectionns中的synchronizedCollection()的方法包装的集合等。
2.1.4 线程兼容
线程兼容是指对象本身并不受线程安全的,但是可以通过在调用段正确地使用同步手段来保证对象在并发环境中安全的使用。我们平常说一个类不是线程安全的,绝大多数指的剧场这种情况。如arraylist和hashmap等。
2.1.5 线程对立
线程对立就是指不管调用端是否采取了同步措施,都无法在多线程并发使用的代码。
2.2 线程安全的实现方法
2.2.1 互斥同步 也叫阻塞同步 悲观策略
互斥同步就是指多个线程并发访问共享数据时,保证共享数据收到同一个时刻只被一条线程使用,而互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方式。
悲观策略的意思就是总是认为只要不去做正确的同步措施,那么就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
在java里面,最基本的互斥同步手段就是synchronized关键字。如果java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
根据虚拟机规范的要求,在执行指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在指令monitorexit指令时会将锁的计数器减一,当计数器为0时,所就被释放了。如果获取获取对象锁失败了,那当前线程就要阻塞等待,知道对象锁被另外一个线程释放为止。
首先我们需要知道,sychronized同步块对同一条线程来说是可重入,不会出现自己把自己锁死的问题。其次,==同步块在已进入的线程执行完之前,会阻塞后面其他线程进入。==sychronized在java中是一个重量级操作。
除了synchronized之外,我们还可使使用java.util.concurrent包中的重入锁ReentrantLock来实现同步,在基本用法上,它们二者很相似,都具备一样的线程重入特征,知识代码写法上有点区别。
sychronized表现为原生语法方面的API。
ReentrantLock使用lock和unlock方法配合try finally语句块来完成。但是它增加了一些高级功能,主要有下边的几个条件:
-
等待可中断
就是指持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
-
公平锁
多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁,而非公平锁则不能保证这一点,在锁释放时,任何一个等待锁的线程都有机会获得所。synchornized的锁就是非公平的,ReentrntLock默认情况下也是非公平的。
-
可及锁可以绑定多个条件
2.2.2 非阻塞同步 乐观策略
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题。
乐观策略:先进行操作,如果没有其他线程争用共享数据,那么操作就成功了,如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施(一般就是不断地重试,知道成功为止)。这种乐观的策略的许多实现都不需要把线程挂起,所以叫非阻塞同步。
CAS 就是Compare And Swap 比较并且交换,在CAS指令中,指令有三个操作数,分别是内存位置,(在java中可以理解为变量的内存地址,用V表示),旧的预期值(用A表示),新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。
2.2.3 无同步方案
如果不涉及共享数据的话,那么当然就不需要任何的同步措施区保证安全性。
3.锁优化
就是为了线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
3.1 自旋锁和自适应自旋
如果物理机器只有一个以上的处理器,能让两个或以上的线程同时并行执行,我们旧可以让后面请求锁的那个线程稍微等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只需让线程执行一个忙循环(也就是自旋),这项技术就是所谓的自旋锁。自旋锁在jdk1.6以后是默认开启的。
我们还需要知道,自旋等待不能代替阻塞,不说对处理器数量的要求,自旋等待本身虽然避免的线程切换的开销,但是它是需要占用处理器时间的,如果锁被占用的时间很短,那么自旋锁的效果非常好,但是如果锁被占用的时间长,那么自旋的线程只会拜拜消耗处理器资源,只能带来性能的浪费。所以我们必须设置自旋等待的时间和次数。
JDK1.6以后引入了自适应的自旋锁,这意为这自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。如果上次成功,那么虚拟机会认为这次也会成功,会允许自旋等待相对而言长的时间。反之亦然。
3.2 锁的消除
锁消除就是值虚拟机在即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,主要判定的依据来源于逃逸分析的数据支持。
3.3 锁的粗化
如果虚拟机探测到由一串零碎的操作都对同一个对象加锁,那么就会将加锁同步的范围扩展(粗化)到整个操作的序列的外部。
3.4 轻量级锁
传统的锁都是重量级的,轻量级锁不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
我们需要知道,如果没有竞争,那么无疑轻量级锁是好的,但是如果有竞争的话,那么轻量级锁的效率反而没有重量级锁的效率高。
3.5 偏向锁
如果说轻量级锁实在无竞争的情况下使用CAS操作去除同步使用的互斥量,那么偏向锁就是在无竞争的请胯下把整个同步的消除掉,连CAs都不做了。
偏向锁的意思就是这个锁会偏向于一个获得它的线程,如果接下来的执行过程中,该锁没有被其他的线程所获得,那么持有偏向锁的线程将永远不需要同步。
偏向锁可以提高带有同步但无竞争的程序性能。