目录
Java内存模型与线程
概述
硬件的效率与一致性
Java内存模型
主内存与工作内存
内存间交互操作
对于volatile型变量的特殊规则
原子性、可见性与有序性
先行发生原则
Java与线程
线程实现
线程调度
状态切换
小结
线程安全与锁优化
概述
线程安全
Java中的线程安全
线程安全的实现方法
锁优化
总结
《深入理解Java虚拟机》是周志明先生所著,主要介绍了:Java发展历程、自动内存管理机制、垃圾收集器与内存分配策略、程序编译与代码优化和高效并发。
本文介绍高效并发。主要分为:Java内存模型与线程、线程安全与锁优化。之前笔者写过《并发编程入门》系列,可以对照学习。
Java内存模型与线程
概述
为什么要并发处理?并发处理是什么?
计算机系统中,由于 CPU 的速度比较快,而其他 I/O 设备(如磁盘、网络等)的速度比较慢,因此在处理大量任务时,CPU 往往会处于等待状态,这样会导致系统资源的浪费和响应速度的降低。而并发处理可以利用多个 CPU 核心同时处理多个任务,从而提高系统的资源利用率和响应速度。
另外,现代应用程序往往需要处理大量的并发请求和数据,例如 Web 服务器、数据库系统等。如果没有良好的并发处理机制,这些系统很容易就会出现瓶颈和性能问题,从而影响系统的可用性和可靠性。
并发处理是指在一个时间段内同时处理多个任务的能力,它可以提高系统的资源利用率、响应速度和吞吐量,从而提高系统的性能和并发能力。
硬件的效率与一致性
处理器、高速缓存、主内存间的交互关系
由于 CPU 访问内存的速度比较慢,为了提高 CPU 的访问速度,计算机系统引入了高速缓存技术和指令重排序优化。
高速缓存是一种小而快速的存储设备,它可以存储最近经常访问的数据,从而避免了访问内存的时间延迟。需要补充的是:数据访问通常可以分为三个层次:寄存器(访问最快,容量非常有限)、内存(容量较大,访问速度相对较慢)和外部存储设备(如硬盘、网络存储等,容量大,速度非常慢)。
指令重排序技术是指处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致
高速缓存引出新问题:如何去保证缓存一致性?指令重排序引出新问题:多线程程序中的数据竞争和数据可见性问题(某些修改操作的结果对其他线程不可见)。
Java内存模型
Java内存模型屏蔽了各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。定义多线程程序中共享变量的访问和修改方式。
主内存与工作内存
-
所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝;变量是指:实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
-
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,线程间变量值的传递均需要通过主内存来完成。
内存间交互操作
在 Java 中,内存间交互操作包括两个基本操作:读取(load)和存储(store)。读取操作是从内存中获取变量的值,存储操作是将变量的值写回到内存中。
在读取操作中,可以分为两个阶段:首先从主内存中读取变量的值到工作内存中,然后线程从工作内存中获取该变量的值。在存储操作中,也可以分为两个阶段:首先将变量的值写入工作内存中,然后将工作内存中的值写回到主内存中。
线程、主内存、工作内存三者的交互关系
对于volatile型变量的特殊规则
最轻量级别的同步机制。用于修饰变量,它有以下特性:
-
可见性:对一个 volatile 变量的写操作会立即刷新到主内存中,对该变量的读操作会从主内存中获取最新的值。这保证了 volatile 变量的可见性,即线程对变量的修改对其他线程是可见的。
-
有序性,禁止指令重排序:volatile 变量的读写操作具有有序性,即操作的顺序是按照程序代码的顺序执行的。这保证了 volatile 变量的操作不会受到指令重排序的影响。如何实现指令重排序?JVM 会创建内存屏障,指令重排序无法越过内存屏障,内存屏障是指字节码空操作加锁:“lock addl $0x0,(%esp)”
-
原子性:对于 volatile 变量的读写操作都具有原子性,即操作是不可分割的。这保证了 volatile 变量的操作是线程安全的。
需要注意的是,volatile 变量的原子性仅保证对单个变量的操作是原子的,对于多个 volatile 变量的操作,仍然需要使用其他的同步机制来保证其原子性。比如:
//最终输出结果大概率不是10000,原因是:
//volatile只能保证可见性,无法保证原子性,increase() 方法中包含了读取和写入操作,这两个操作虽然都是原子操作,但是它们并不是一个原子操作。因此,在多线程环境下,这些操作可能会出现交叉执行的情况,导致 race 的值不是预期的值。
//可通过加锁来解决。synchronized或AtomicInteger。
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
public static void main(String[] args) {
for (int i = 0; i < 500; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 20; j++) {
increase();
System.out.println(race);
}
}
}).start();
}
}
}
一般来说,商用虚拟机选择把64位数据的读写操作(long和double)作为原子操作来对待。但可见性仍需volatile关键字。
原子性、可见性与有序性
-
原子性:原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。基本数据类型的读写是具备原子性的;另外,Java 中的 synchronized 关键字(monitorenter和monitorexit隐式执行)、 Lock 接口和原子操作类(AtomicXXX)也可以用来保证代码块或方法的原子性。
-
可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。以Volatile为例,其保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。另外,Java中的synchronized和final能保证可见性。
-
有序性:有序性是指程序中的操作执行顺序与代码的书写顺序一致。包括两方面:程序顺序性和指令重排序。Java 提供了volatile、synchronized和Lock来保证有序性。
先行发生原则
通过遵守 happens-before 原则,Java 程序可以保证多线程之间的操作顺序和可见性,从而避免出现数据竞争和线程安全问题。
为了保证内存的可见性和有序性,Java 内存模型定义了 happens-before 规则,用于规定在多线程环境下,一个操作的结果对另一个操作的可见性和顺序关系。所谓 happens-before :
-
程序顺序规则:单个线程,它的所有操作执行的顺序必须与程序代码中的顺序一致。
-
volatile 变量规则:对一个 volatile 变量的写操作必须先于后续的读操作。这保证了 volatile 变量的可见性和有序性。
-
传递性规则:如果操作 A happens-before 操作 B,操作 B happens-before 操作 C,则操作 A happens-before 操作 C。
-
synchronized 规则:对于同一个锁,线程对锁的解锁操作必须先于后续的加锁操作。这保证了 synchronized 块中的操作的可见性和有序性。
-
线程启动和终止规则:线程的 start() 方法必须先于线程中的任何操作;线程的所有操作必须先于线程的终止操作。
Java与线程
线程实现
线程的实现可以基于轻量级进程或者用户级线程。在基于进程的实现中,每个线程都是一个独立的进程,由操作系统来进行调度,并拥有自己的地址空间。在基于用户级线程的实现中,多个线程共享同一个进程空间,线程的调度由用户级线程库来完成,操作系统并不直接参与线程的调度。
Java 线程的底层实现是基于操作系统提供的线程机制。在 Java 中,每个线程都会被映射到操作系统的一个线程中,在操作系统中,线程是最基本的调度单位。Java 程序员不需要直接操作线程,只需要使用 Java 提供的线程库就可以实现多线程编程。
线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。在 Java 中,线程调度是由操作系统来完成的,因此 Java 中的线程调度策略是抢占式调度。
协同式调度是指线程自己选择何时让出 CPU 的控制权;抢占式调度是指操作系统强制剥夺正在执行的线程的 CPU 时间片,并将 CPU 交给其他优先级更高的线程执行。
状态切换
生命周期包括新建(NEW)、就绪(RUNNABLE)、运行(RUNNING)、阻塞(BLOCKED)和销毁(TERMINATED)。详见:并发编程入门(一):多线程基础
小结
本章首先介绍了Java内存模型,分别为:主内存与工作内存是什么?它们是如何交互的?原子性、可见性与有序性;Volatile是如何保证三大特性的;先行发生原则。
其次,我们介绍了Java与线程,分别为:线程实现、线程调度以及线程状态间的切换。
线程安全与锁优化
概述
从面向过程到面向对象,提升了生产效率和软件规模。与此同时,发现存在并发问题。例如,人们很难想象现实中的对象在一项工作进行期间,会被不停地中断和切换,对象的属性(数据)可能会在中断期间被修改和变“脏”,而这些事件在计算机世界中则是很正常的事情。
线程安全
Java中的线程安全
框定范围,线程安全限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说是完全没有区别的。
按照线程安全的“安全程度”由强至弱,我们将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变:
Java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的(final
关键字可以用于修饰变量、方法和类,当一个变量被 final
修饰时,它的值被初始化后就不能再被改变;当一个方法被 final
修饰时,它不能被子类重写或覆盖;当一个类被 final
修饰时,它不能被继承。)。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。比如:枚举类型、String类的API(不会影响原来的值,返回新字符串对象)、Number的部分子类等。
绝对线程安全:
Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。Vector是一个线程安全的容器,因为它的add()、get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了。在多线程的环境中,如果不在方法调用端做额外的同步措施的话,同时add或者remove是不安全的。
相对线程安全:
相对的线程安全就是我们通常意义上所讲的线程安全,需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。比如:Vector、HashTable、Collections的synchronizedCollection()。
线程兼容:
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。比如:ArrayList和HashMap等。
线程对立:
线程对立线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。Java语言天然具备多线程特性。
线程安全的实现方法
互斥同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。
-
synchronized关键字:(1)同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。(2)synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。(3)同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
-
可重入锁ReentrantLock:相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断(当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待)、可实现公平锁(公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。),以及锁可以绑定多个条件(绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象)。
提倡用Sychronized,因为JVM一直在优化Sychronized的性能;Sychronized使用简单,不容易死锁。
非阻塞同步
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题;非阻塞同步是指先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)。
CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。eg:AtomicInteger的incrementAndGet()方法。
CAS存在ABA问题(线程1在操作A时,线程2可能会将A改为B,再改回A,线程1错误认为变量没有被修改),大多数ABA不会影响并发正确性。解决方案:加入时间戳纬度;使用Sychronized。
无同步方案
有些代码是天生线程安全的,未涉及共享数据,自然无需任何同步去保证正确性。
可重入代码:可以被多个任务同时调用而不会导致错误或竞争条件的代码。特征:(1)不依赖于全局变量或静态变量,或者使用局部变量或参数来存储状态信息;(2)不使用非可重入函数或系统调用,如 malloc 和 sleep 等;(3)不会在执行期间修改自身的代码或数据结构。
线程本地存储:如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。
锁优化
锁消除
可重入代码,自然无须加锁。
锁粗化
大多数情况下,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步。但是,如果一系列的连续操作都对同一个对象反复加锁和解锁,不妨锁粗化,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级锁
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
自旋锁
自旋锁不会将线程阻塞,而是通过循环等待的方式来占用 CPU 时间,直到该锁变为可用。以此为基础,延伸出自适应自旋锁,它可以自适应地调整自旋锁的自旋时间,以提高多线程程序的性能和可伸缩性。
偏向锁
锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
总结
Java中的线程安全从强到弱分别是不可变(final、String等API)、绝对线程安全(一般不存在)、相对线程安全(Vector、HashTable等,内部安全,需保证调用安全)、线程兼容(ArrayList和HashMap,对象本身不安全)和线程对立(一般不存在)。
线程安全的实现方法有两种:互斥同步(Synchronized和ReentrantLock)、非阻塞同步(CAS)和无同步方案(可重入代码和本地线程存储)。
锁优化有五种优化手段:锁消除、锁粗化、轻量级锁、自旋锁和偏向锁。