作者简介
善光,一个半年才打一次篮球的程序猿。一直从事 Java 化开发,目前负责物流压力平衡、千里眼和风暴眼 Java 化项目。
引言
一般的应用系统中,存在着大量的计算和大量的 I/O 处理,通过多线程可以让系统运行得更快。但在 Java 多线程编程中,会面临很多的难题,比如线程安全、上下文切换、死锁等问题。
线程安全
引用 《Java Concurrency in Practice》 的作者 Brian Goetz 对线程安全的定义:
线程安全,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的
那么如何实现线程安全呢?互斥同步是最常见的一种线程安全保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 有两种使用形式,分别为同步方法和同步代码块:
/**
* 同步方法在执行前先获取一个监视器,如果是一个静态方法,监视器关联这个类,如果是一个实例方法,
* 则关联这个调用方法的对象。同步方法是隐式的,同步方法常量池中会有一个 ACC_SYNCHRONIZED 标
* 志,当某个线程访问某个方法的时候,会检查是否有 ACC_SYNCHRONIZED,如果有,则需要先获得监
* 视器锁,然后开始执行方法,方法执行之后再释放监视器锁,这时候如果有其他线程来请求执行该方法,
* 会因为无法获得监视器锁而被阻塞住。
*/
class Test {
int count;
synchronized void bump(){
count++;
}
static int classCount;
static synchronized void classBump(){
classCount ++;
}
}
/**
* 同步块在执行前先获取一个监视器,监视器关联括号里面 expression 引用的对象。 同步代码块则是使用
* monitorenter 和 monitorexit 两个指令实现的,monitorenter 可以理解为加锁,monitorexit 可以理解为
* 释放锁,每个对象自身维护着一个被加锁次数的计数器,当计数器为 1 时,只有获得锁的线程才能再次获
* 得锁,即可重入锁,当计数器为0时表示任意线程可以获得该锁。
* synchronized 的 expression 只能是引用类型,否则会发生编译错误,如果为空,则会抛出空指针异常,
* 不管同步块中的代码是否正常执行完,监视器都会解锁。
*/
synchronized (expression){
// block code
}
那么对象如何与监视器关联呢,在 Java 中,对象包含三块:对象头、实例数据、填充数据,其中对象头中就包含 Mark Word,Mark Word 一般存储对象的 hashCode、GC分代年龄以及锁信息,锁信息就包含指向互斥量(重量级锁)的指针,指向了一个监视器;监视器是通过 ObjectMonitor 来实现的,代码如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
从上面代码可以看到有 ObjectMonitor 两个队列,分别是 _WaitSet 和 _EntryList,_owner 指向持有 ObjectMonitor 对象的线程,当多个线程获取到对象 monitor 后进入 _owner 区域,并把 _owner 设置为指向当前线程,并把 _count 数量加1;当调用 wait() 方法后,将释放当前持有的 monitor,_owner 置为空,_count 减 1 操作,同时,将该线程进入 _WaitSet 集合中等待唤醒,总结如下图:
上下文切换
现代操作系统中,运行一个程序,系统会为它创建一个进程。现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享内存变量。
主要有三种方式实现线程:内核线程实现、用户线程实现、用户线程加轻量级进程混合实现。Java 里面的 Thread 类,它的所有关键方法都是声明 Native 的,和平台有关的方法。从 JDK 1.2 起,对于 Sun JDK 来说,它的 Windows 版与 Linux 版都使用一对一的线程模型实现,一条 Java 线程就映射到一条轻量级进程中,程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程,轻量级进程就是我们通常意义上所说的线程,由于每个轻量级进程都由一个内核线程支持,因此,只有先支持内核级线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型。
由于有内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程阻塞了,也不会影响整个进程的工作,但是轻量级进程有它的局限性,每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量是有限的,其次,系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernal Mode)中来回切换,线程上下文切换直接的损耗CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉。对于抢占式操作系统来说:
- 当前执行任务的时间片用完之后,系统CPU正常调度下一个任务
- 当前执行任务碰到IO阻塞,调度器将此任务挂起,继续下一任务
- 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务
- 用户代码挂起当前任务,让出CPU时间
- 硬件中断
综上所述,在 JDK 1.5 之前,通过 synchronized 关键字是保证线程安全的一种重要手段,但是 synchronized 是一个重量级锁,为什么说 synchronized 是一个重量级锁呢?因为 synchronized 依赖于操作系统的 MutexLock(互斥锁)来实现的,且等待获取锁的线程将会阻塞,被阻塞的线程不会消耗 CPU,但是阻塞或唤醒一个线程都需要涉及到上下文切换,涉及到用户态和内核态的切换,所以是比较耗时的。
那除了 synchronized 之外,我们还有其他的方案吗?答案是肯定的,我们可以使用 java.util.concurrent 的 ReentrantLock,但是在了解 ReentrantLock 之前,我们先了解下同步器框架。
同步器框架
概要
在 JDK1.5 的 java.util.concurrent 包中,大部分的并发类都是基于 AbstractQueuedSynchronizer(简称 AQS)