1 synchronized的好处
隐式获取释放锁,简化了同步的管理,十分便捷。
2 锁的优势
(1)拥有锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
(2)扩展性和灵活性好。例如,针对一个场景,手把手进行锁获取和释放,先获得锁A,然后再获取锁B,当锁B获得后,释放锁A同时获取锁C,当锁C获得后,再释放B同时获取锁D,以此类推。这种场景下,synchronized关键字就不那么容易实现了,而使用Lock却容易许多。
3 锁得使用方式
Lock的使用也很简单,下面是Lock的使用的方式。
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
(1)在finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放。
(2)不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放。
4 锁得常用API
5 重入锁
重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。
5.1 重入锁的简单实现(非JDK的实现)
public class SimpleReentrantLock {
// 是否占有锁资源的标识位
private boolean isLocked = false;
// 当前锁资源被哪个线程持有
private Thread lockedByThread = null;
// 某线程上锁次数
private int lockCount = 0;
public void lock() {
synchronized (this) {
Thread currentThread = Thread.currentThread();
// 如果锁资源被占有了,并且当前线程并不是占有锁资源的线程。
while (isLocked && currentThread != lockedByThread) {
try {
wait();
} catch (InterruptedException e) {
}
}
isLocked = true;
lockCount++;
lockedByThread = currentThread;
}
}
public void unlock() {
// 如果当前线程就是获得锁资源的线程,那么就lockCount进行自减.
if (Thread.currentThread() == this.lockedByThread) {
lockCount--;
if (lockCount == 0) {// 如果lockCount恢复到0时就释放资源
isLocked = false;
notify();
}
}
}
}
5.2 重入锁的释放
重入锁必须保证每次获取的锁都被释放才算是完全释放。
重入锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
例如线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。
5.3 重入锁的公平性
如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。
公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以TPS作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
下面是示例代码:
package com.niuniu.study.spring;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.junit.Test;
public class FairAndUnfairTest {
private static Lock fairLock = new ReentrantLock2(true);
private static Lock unfairLock = new ReentrantLock2(false);
@Test
public void fair() {
testLock(fairLock);
}
@Test
public void unfair() {
testLock(unfairLock);
}
private void testLock(Lock lock) {
// 启动5个Job(略)
}
private static class Job extends Thread {
private Lock lock;
public Job(Lock lock) {
this.lock = lock;
}
public void run() {
// 连续2次打印当前的Thread和等待队列中的Thread(略)
}
}
private static class ReentrantLock2 extends ReentrantLock {
public ReentrantLock2(boolean fair) {
super(fair);
}
public Collection<Thread> getQueuedThreads() {
List<Thread> arrayList = new ArrayList<Thread>(
super.getQueuedThreads());
Collections.reverse(arrayList);
return arrayList;
}
}
}
分别运行fair()和unfair()两个测试方法,输出结果如下所示:
===========================================================
===========================================================
观察表所示的结果(其中每个数字代表一个线程),公平性锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。
为什么会出现线程连续获取锁的情况呢?这是因为当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。
非公平性锁可能使线程“饥饿”,为什么它又被设定成默认的实现呢?这是因为公平性锁虽然保证了锁的获取按照FIFO原则,但是代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
6 读写锁
读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
6.1 读写锁的好处
(1)读写锁能够简化读写交互场景的编程方式
假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。
(2)读写锁的性能都会比排它锁好。
因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
6.2 读写锁的简单实现(非JDK的实现)
package com.niuniu.studyDemo;
import java.util.HashMap;
import java.util.Map;
public class SimpleReentrantLockReadWriteLock {
// 读线程数
private int readersCount = 0;
// 写线程数(在0/1之间变化)
private int writersCount = 0;
// 请求写线程数
private int requestWriters = 0;
// 持有读锁线程容器
private Map<Thread, Integer> readerThreads = new HashMap<Thread, Integer>();
// 持有写锁的线程
private Thread writeThread = null;
// 获取读锁
public void readLock() {
synchronized (this) {
Thread currentThread = Thread.currentThread();
// 如果存在写线程或请求写线程,或者当前线程没有持有读锁,那么就等待.
while (writersCount > 0 || requestWriters > 0
|| !isCurrentThreadHoldReadLock(currentThread)) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
readerThreads.put(currentThread,
(getCurrentThreadCount(currentThread) + 1));
}
}
// 释放读锁
public void unlockReadLock() {
synchronized (this) {
Thread currentThread = Thread.currentThread();
int count = getCurrentThreadCount(currentThread);
if (count == 1) {
readerThreads.remove(currentThread);
} else {
readerThreads.put(currentThread, (count - 1));
}
notifyAll();
}
}
/**
* 当前线程是否持有读锁
*
* @param currentThread
* @return
*/
public boolean isCurrentThreadHoldReadLock(Thread currentThread) {
return readerThreads.containsKey(currentThread);
}
/**
* 获得当前线程上锁次数
*
* @param currentThread
* @return
*/
public int getCurrentThreadCount(Thread currentThread) {
Integer count = readerThreads.get(currentThread);
if (count == null)
return 0;
return count.intValue();
}
// 获取写锁
public void writeLock() {
synchronized (this) {
Thread currentThread = Thread.currentThread();
requestWriters++;
// 如果存在写线程或读线程并且当前线程没有持有锁就等待
while (readersCount > 0 || writersCount > 0
|| (writeThread != null && currentThread != writeThread)) {
try {
wait();
} catch (InterruptedException e) {
}
}
requestWriters--;
writersCount++;
writeThread = currentThread;
}
}
// 释放写锁
public void unlockWriteLock() {
synchronized (this) {
writersCount--;
if (writersCount == 0) {
writeThread = null;
}
notifyAll();
}
}
}
6.3 ReentrantReadWriteLock
Java并发包提供读写锁的接口类是ReadWriteLock,它定义了获取读锁和写锁的两个方法,即readLock()方法和writeLock()方法,其实现是ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,这些方法如下表所示。
接下来,通过一个缓存示例说明读写锁的使用方式,示例代码如下所示。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
static Map<String, Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 获取一个key对应的value
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 设置key对应的value,并返回旧的value
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的内容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。Cache使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。
6.4 读写锁的锁降级
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,代码如下所示。
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。
锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
7 Condition接口
Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式。
Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition是依赖Lock对象的。
Condition的使用方式比较简单,需要注意在调用方法前获取锁,使用方式如下代码所示。
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void conditionSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}
}
如示例所示,一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。
Condition定义的(部分)方法以及描述如下表所示。
获取一个Condition必须通过Lock的newCondition()方法。下面通过一个有界队列的示例来深入了解Condition的使用方式。有界队列是一种特殊的队列,当队列为空时,队列的获取操作将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插入操作将会阻塞插入线程,直到队列出现“空位”,如下面代码所示。
package com.niuniu.study.spring;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedQueue<T> {
private Object[] items;
// 添加的下标,删除的下标和数组当前数量
private int addIndex, removeIndex, count;
private Lock lock = new ReentrantLock();
private Condition notEmpty = lock.newCondition();
private Condition notFull = lock.newCondition();
public BoundedQueue(int size) {
items = new Object[size];
}
// 添加一个元素,如果数组满,则添加线程进入等待状态,直到有"空位"
public void add(T t) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[addIndex] = t;
if (++addIndex == items.length)
addIndex = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 由头部删除一个元素,如果数组空,则删除线程进入等待状态,直到有新添加元素
@SuppressWarnings("unchecked")
public T remove() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
Object x = items[removeIndex];
if (++removeIndex == items.length)
removeIndex = 0;
--count;
notFull.signal();
return (T) x;
} finally {
lock.unlock();
}
}
}
上述示例中,BoundedQueue通过add(T t)方法添加一个元素,通过remove()方法移出一个元素。以添加方法为例。
首先需要获得锁,目的是确保数组修改的可见性和排他性。当数组数量等于数组长度时,表示数组已满,则调用notFull.await(),当前线程随之释放锁并进入等待状态。如果数组数量不等于数组长度,表示数组未满,则添加元素到数组中,同时通知等待在notEmpty上的线程,数组中已经有新元素可以获取。
在添加和删除方法中使用while循环而非if判断,目的是防止过早或意外的通知,只有条件符合才能够退出循环。回想之前提到的等待/通知的经典范式,二者是非常类似的。