线程安全章节我们分析了并发编程遇到的常见问题,并在文章的最后提到如何解决并发问题,其中提到了通过同步机制来解决共享变量有状态问题。
同步概念
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
- 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
- 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型,需要程序员显示的指定某个方法或某段代码需要线程之间互斥执行。
同步的目的:在多线程编程里面,一些敏感共享资源不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性。
java中同步方法
从广义来说,java平台提供的同步机制有锁、volatile关键字、final关键字、wait/notifyAll以及并发包下的工具类如:Semaphore、Condition等。下面就其中常见同步机制进行说明:
锁概述
利用锁对共享变量提供保障,一个线程访问共享数据前必须申请相应的锁。当线程获得某个锁,称该线程为锁的持有线程,一个锁一次只能被一个线程持有。锁的持有线程可以对该锁保护的共享变量进行访问,并在访问结束后释放相应的锁。
锁的持有线程在获取锁之后和释放锁之前这段时间执行的代码被称为临界区。共享变量只允许在临界区内进行访问,临界区一次只能被一个线程执行。具体可以用下图示意:
Java平台的锁包括内部锁——synchronized关键字实现和显式锁通过java.concurrent.locks.Lock接口实现。
synchronized
Java平台中任何一个对象都有唯一一个与之关联的锁,被称为监视器(Monitor)或内部锁。这个内部锁通过synchronized关键字实现的。synchronized有三种常用的方式如下:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
复制代码
- 修改非静态方法锁定的是当前对象实例this
- 修饰静态方法锁定的是当前类的Class对象
- 修饰代码块锁定的是obj对象
使用synchronized一定要搞清楚自己锁定的对象是谁,保护的共享变量是谁。
synchronized原理
首先我们反编译synchronized使用中给出的示例的代码如下图所示,其中标注出了monitorenter和monitorexit两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
java程序中如果synchronized明确指定了对象参数,那么就是这个对象的reference;如果没有明确指出,则根据synchronized修饰的实例方法还是类方法来确定,如果是类方法则取Class对象作为锁对象。总结下来,锁对象分为如下两类- synchronized(this)以及非static的synchronized方法,则锁定调用对象本身
- static修饰的静态方法以及synchronized(xxx.class),则锁定类的Class对象,因为一个类的Class对象只有一个,所以该类的所有相关对象都共享一把锁。
根据jvm规范,在执行monitorenter指令,线程首先要尝试获取reference对应的对象锁
- 如果该对象锁没有被锁定占有,或者改线程之前已经拥有了该对象锁,则把锁的计数器加1。
- 相应地,在执行monitorexit指令时会将该对象锁计数器减1,当计数器为0时,锁就被释放了。其他被这个对象锁阻塞的线程可以尝试去获取这个对象锁的所有权。
Lock
javaSE5之后,并法包新增了Lock接口用来实现锁功能,它提供了与synchronized类似的同步功能,只是使用的时候需要显示的获取和释放锁,同时这些接口也提供了synchronized不具备的特性如下表所示:
特性 | 描述 |
---|---|
尝试非阻塞获取锁 | 当前线程尝试获取锁,如果这一刻锁没有被其他线程获取到,则成功获取持有锁 ,不会阻塞等待锁释放 |
被中断的获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到的锁的线程被中断时,中断异常将会被抛出,同时锁会被释放 |
超时获取锁 | 接口在指定的截止时间之前获取锁,如果截止时间到了依旧无法获取锁,则返回 |
对应上述特性的代码:
// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();
复制代码
ReentrantLock重入锁
ReentrantLock是Lock接口一种常见的实现,它是支持重进入的锁即表示在调用lock()方法时,已经获取锁的线程能够再次调用lock()方法而不被阻塞。同时,该锁还支持获取锁时的公平与非公平的选择。 最后,ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问。 关于公平与非公平几点说明:
- 如果在绝对时间上,先对于锁进行获取的请求一定先被满足,那么这个锁就是公平的,反之就是非公平的。
- 公平的获取锁也就是等待时间最久的线程优先获取到锁。ReentrantLock的构造函数来控制是否为公平锁。
- 通常情况下,公平锁保证了获取锁按照FIFO原则,而代价就是大量的线程切换,导致性能下降。而非公平有可能导致部分线程饥饿,但是保证了更大的吞吐量。
ReentrantLock通用使用模式如下注意主动释放锁:
private final Lock rtl = new ReentrantLock();
// 获取锁
rtl.lock();
try {
// 临界区
} finally {
// 保证锁能释放
rtl.unlock();
}
复制代码
读写锁
前面提到的ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问,而读写锁在同一时刻允许可以有多个线程来访问,但在写线程访问时,所有的读线程和其他写线程被阻塞。 读写锁维护了一对锁,一个读锁和一个写锁,其中读锁是一个共享锁可以被多个线程同时获取,而写锁是一个支持冲进入的排它锁。读写锁实例ReentrantReadWriteLock有以下特性:
- 公平性选择:和ReentrantLock类似
- 重进入:可重入锁,特别注意写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级为读锁
- 任何一个线程持有一个读锁的时候,其他任何线程都无法获取相应锁的写锁。这保证了读线程在读取共享变量期间没有其他线程能够对其进行更新
public class Cache<K, V> {
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private ReentrantReadWriteLock.ReadLock r = rwl.readLock();
private ReentrantReadWriteLock.WriteLock w = rwl.writeLock();
private Map<K, V> cache = new HashMap();
public V getKey(K key) {
V result = null;
r.lock();
try {
result = cache.get(key);
} finally {
r.unlock();
}
if (result != null) {
return result;
}
w.lock();
try {
result = cache.get(key);
if (result == null) {
// db查获取value
V v = null;
result = v;
putValue(key, v);
}
} finally {
w.unlock();
}
return result;
}
public V putValue(K key, V value) {
w.lock();
try {
return cache.put(key, value);
}finally {
w.unlock();
}
}
}
复制代码
该示例中使用非线程安全的HashMap作为缓存实现,通过使用读写锁来保证线程的安全。分析代码,在读取操时需要获取读锁为共享锁支持多线程同时访问不被阻塞。在写操作时,首先获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,只有写锁被释放后,其他操作才可以继续,这样也保证了所有读操作都是最新数据。
轻量级同步volatile
volatile关键字常被称为轻量级锁,其作用和锁的作用有相同的地方:保证可见性和有序性。具体分析可以见java内存模型里面有详细分析。