线程安全章节我们分析了并发编程遇到的常见问题,并在文章的最后提到如何解决并发问题,其中提到了通过同步机制来解决共享变量有状态问题。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
Java的并发采用的是共享内存模型,需要程序员显示的指定某个方法或某段代码需要线程之间互斥执行。
同步的目的:在多线程编程里面,一些敏感共享资源不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性。
从广义来说,java平台提供的同步机制有锁、volatile关键字、final关键字、wait/notifyAll以及并发包下的工具类如:Semaphore、Condition等。下面就其中常见同步机制进行说明:
利用锁对共享变量提供保障,一个线程访问共享数据前必须申请相应的锁。当线程获得某个锁,称该线程为锁的持有线程,一个锁一次只能被一个线程持有。锁的持有线程可以对该锁保护的共享变量进行访问,并在访问结束后释放相应的锁。
锁的持有线程在获取锁之后和释放锁之前这段时间执行的代码被称为 临界区 。共享变量只允许在临界区内进行访问,临界区一次只能被一个线程执行。具体可以用下图示意:
锁具有排他性——一个锁一次只能被一个线程持有。即同一时刻只有一个线程可以执行临界区的代码。
Java平台的锁包括内部锁——synchronized关键字实现和显式锁通过java.concurrent.locks.Lock接口实现。
Java平台中任何一个对象都有唯一一个与之关联的锁,被称为监视器(Monitor)或内部锁。这个内部锁通过synchronized关键字实现的。synchronized有三种常用的方式如下:
class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } } 复制代码
使用synchronized一定要搞清楚自己锁定的对象是谁,保护的共享变量是谁。
首先我们反编译synchronized使用中给出的示例的代码如下图所示,其中标注出了monitorenter和monitorexit两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
java程序中如果synchronized明确指定了对象参数,那么就是这个对象的reference;如果没有明确指出,则根据synchronized修饰的实例方法还是类方法来确定,如果是类方法则取Class对象作为锁对象。总结下来,锁对象分为如下两类
根据jvm规范,在执行monitorenter指令,线程首先要尝试获取reference对应的对象锁
javaSE5之后,并法包新增了Lock接口用来实现锁功能,它提供了与synchronized类似的同步功能,只是使用的时候需要显示的获取和释放锁,同时这些接口也提供了synchronized不具备的特性如下表所示:
特性 | 描述 |
---|---|
尝试非阻塞获取锁 | 当前线程尝试获取锁,如果这一刻锁没有被其他线程获取到,则成功获取持有锁 ,不会阻塞等待锁释放 |
被中断的获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到的锁的线程被中断时,中断异常将会被抛出,同时锁会被释放 |
超时获取锁 | 接口在指定的截止时间之前获取锁,如果截止时间到了依旧无法获取锁,则返回 |
对应上述特性的代码:
// 支持中断的 API void lockInterruptibly() throws InterruptedException; // 支持超时的 API boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 支持非阻塞获取锁的 API boolean tryLock(); 复制代码
ReentrantLock是Lock接口一种常见的实现,它是支持重进入的锁即表示在调用lock()方法时,已经获取锁的线程能够再次调用lock()方法而不被阻塞。同时,该锁还支持获取锁时的公平与非公平的选择。 最后,ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问。 关于公平与非公平几点说明:
ReentrantLock通用使用模式如下注意主动释放锁:
private final Lock rtl = new ReentrantLock(); // 获取锁 rtl.lock(); try { // 临界区 } finally { // 保证锁能释放 rtl.unlock(); } 复制代码
前面提到的ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问,而读写锁在同一时刻允许可以有多个线程来访问,但在写线程访问时,所有的读线程和其他写线程被阻塞。 读写锁维护了一对锁,一个读锁和一个写锁,其中读锁是一个共享锁可以被多个线程同时获取,而写锁是一个支持冲进入的排它锁。读写锁实例ReentrantReadWriteLock有以下特性:
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关键字常被称为轻量级锁,其作用和锁的作用有相同的地方:保证可见性和有序性。具体分析可以见java内存模型里面有详细分析。