一、Java内存模型
Java内存模型(Java Memory Model,JMM)是Java虚拟机定义的,用来屏蔽掉各种硬件和操作系统的内存访问差异,使Java程序在各种平台上都能实现内存访问的一致性。
1.1、主内存与工作内存
- Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。
- 每条线程都有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程变量所有的操作(读取、赋值等)都必须在工作内存中进行,而不能直接读取主内存中的变量。
线程间如何进行数据交互?
- 不同线程之间无法直接访问对方工作内存中的变量,线程间变量的传递需要通过主内存来完成
线程、主内存、工作内存交互关系如下图:
1.2、内存间交互操作
线程之间的交互必须经过主内存,可以分为以下两个部分
- 将变量从工作内存同步到主内存
- 将变量从主内存拷贝到工作内存
Java内存模型定义了8种操作来完成内存交互过程,并且虚拟机实现时必须保证每一种操作都是原子的、不可再分割的。(对于double和long类型变量来说,load、store、read 和 write 操作再某些平台上运行有例外)
- lock(锁定):作用于主内存的变量,将一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,将一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,将一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,将read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,将工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,将一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,将工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,将store操作从工作内存中一个变量的值传送到主内存的变量中。
如果将一个变量从主内存复制到工作内存,那么需要顺序地执行:read 和 load
如果将一个变量从工作内存同步到主内存,那么需要顺序地执行:store 和 load
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
1.3、volatile型变量规则
volatile关键字是Java虚拟机提供地最轻量级的同步机制。
作用:保持内存可见性和防止指令重排序
1.3.1、可见性:
可见性指:当一条线程修改了这个变量的值,新值对于其它线程来说是可以立即得知的。
当一个变量被volatile
修饰后,就具备了对所有线程的可见性。普通变量的值在线程间传递需要通过主内存来完成,因此做不到可见性。
注意:
虽然volatile
变量对所有线程是立即可见的,对volatile变量所有的写操作都能立即反应到其它线程中。但这并不代表基于volatile变量的运算在并发下是安全的。
volatile只能保障可见性,需要通过加锁来保障原子性。
1.3.2、防止指令重排序:
普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的 “线程内表现为串行的语义”。
通过下面一段代码来理解防止指令重排序:
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,读取完成后认为是初始化完成
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程B中执行
// 等待initialized为true后,读取配置信息进行操作
while ( !initialized) {
sleep();
}
// 使用线程A初始化好的配置信息
doSomethingWithConfig();
如果定义 initialized 变量时没有使用 volatile 修饰,就可能会由于指令重排序的优化,导致位于线程A最后一句代码 initialized = true;
被提前执行,这样线程B中使用配置信息的代码可能就会出错,而 volatile 关键字则可避免此类情况发生。
1.3.3、volatile 关键字优缺点:
某些情况,volatile同步机制的性能要优于锁(synchronized关键字和java.util.concurrent里面的锁)。
volatile读操作性能和普通变量几乎没什么差别,但写操作可能会慢一些。
在volatile与锁之间选择的依据:volatile的语义能否满足使用场景
1.3.4、原子性、可见性、有序性
原子性:一个程序,它要么完整的被执行,要么完全不执行。这种特性叫原子性。原子操作:独立的不可分割的操作
可见性:当一个线程修改了共享变量的值,其它线程能够立即得知这个修改
有序性:即程序执行的顺序按照代码的先后顺序执行。如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
Java提供了volatile和synchronizated来保证线程间操作的有序性
二、Java与线程
2.1、Java线程调度器
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度和抢占式线程调度
2.1.1、协同式线程调度
线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上。
- 优点:实现简单,没有线程同步的问题
- 缺点:线程执行时间不可控,甚至一个有编写有问题的线程,一直不告知系统进行线程切换,程序会一直阻塞在那里。
2.1.1、抢占式线程调度
每个线程由系统来分配执行时间,线程的切换不由线程本身来决定。
- 优点:线程执行的时间可控,不会因为线程导致系统阻塞。
Java使用的线程调度方式就是抢占式线程调度
虽然Java使用的是抢占式线程调度,但是我们可以通过设置线程的优先级来给线程分配更多的执行时间(时间片)。因为Java线程是通过映射到系统上的原生线程来实现的,所以线程调度最终还是取决于操作系统,Java线程的优先级并不一定很靠谱
2.2、线程状态转换
Java语言定义了6种线程状态,在任意时间点,一个线程有且只有其中一种状态。
- 新建(New):创建后尚未启动状态
- 运行(Runable):Runable包括了操作系统线程状态种的Running和Ready,即此状态的线程有可能正在执行,也有可能正在等待CPU为它分配执行时间
- 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其它线程显示的唤醒。以下方法会让线程进入无限期的等待状态:
- 没有设置Timeout参数的Object.wait()方法
- 没有设置Timeout参数的Thread.join()方法
- LockSupport.park()方法
- 定时等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无需等待被其它线程显示的唤醒,在一定时间后它们会由系统自动唤醒。以下方法会让线程进入超时等待状态:
- Thread.sleep()方法
- 设置了Timeout参数的Object.wait()方法
- 设置了Timeout参数的Thread.join()方法
- LockSupport.parkNaons()方法
- LockSupport.parkUntil()方法
- 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候方法;而“等待状态”则是在等待一段时间,或者唤醒动作的方法。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
它们的转换关系如下:
三、线程安全与锁优化
3.1、线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是 线程安全 的。
3.1.1、Java语言中的线程安全
相对线程安全:
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
3.1.2、线程安全的实现方法
1、互斥同步
互斥同步是常见的一种并发正确性保障手段。
同步:是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程使用。
互斥:互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方法。因此互斥是因,同步是果;互斥是方法,同步是目的。
synchronized
:是Java中最基本的互斥同步手段
synchronized
同步原理:
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter
和monitorexit
这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指明了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
根据虚拟机规范要求:在执行monitorenter
指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit
指令时会将锁计数器值减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放。
关于monitorenter
和monitorexit
需要注意的是:
- 首先,synchronized同步块对同一线程来说是可重入的,不会出现自己把自己锁死的问题
- 其次,synchronized同步块在已进入的线程执行完之前,会阻塞后面其他线程进入
ReentrantLock
(重入锁):java.lang.concurrent包中
在基本用法上,ReentrantLock
和synchronized
很相似,它们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层的互斥锁。
ReentrantLock特点:ReentrantLock增加了一些高级功能,主要有:等待可中断、可实现公平锁、以及锁可以绑定多个条件。
- 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
- 绑定多个任务:指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()可以实现一个隐含条件,如果要和多于一个的条件关联时,就不得不额外的添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。
如果需要实现上述功能,ReentrantLock是一个很好的选择。
synchronized与ReentrantLock性能比较:
在JDK1.6之后synchronized和ReentrantLock性能基本完全持平了。因此在synchronized能实现的需求下,优先使用synchronized实现同步
2、非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和唤醒锁带来的性能问题,因此这种同步也称为阻塞同步。
悲观锁:
从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
乐观锁:
随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗的讲,就是先进行操作,如果没有其它线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就采取其它的补偿措施(最常见的补偿措施就是不断的重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
为什么说乐观并发策略需要硬件指令集的发展才能进行呢?因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证原子性呢?如果这里再使用互斥同步来保证就是去意义了,所以我们只能靠硬件来完成这件事情。
在JDK1.5之后,Java程序才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的方法提供。
由于Unsafe类不是提供给用户程序调用的类,因此,如果不采用反射手段,我们只有通过Java其它API来使用它,如java.util.concurrent包里面的整数原子类,其中的一些方法使用了Unsafe类的CAS操作。
提供原子特性的类有:
AtomicBoolean、AtomicInteger、AtomicIntegerArray、AtomicLong等
3.2、锁优化
高效并发是JDK1.5到JDK1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如适应性自旋、锁消除、锁粗话、轻量级锁和偏向锁等。