在多核架构的系统下,可能同时有多个程序正在被执行,如果不加限制的访问共享资源,将会影响程序运行的正确性。
保证访问共享资源的进程之间是互斥的,就能保证程序执行的原子性。
通过 synchronized
实现的管程
在Java中,通过 synchronized
关键字提供互斥原语,对对象尝试加锁,如果成功,则进入临界区。否则就等待,直到持有锁的线程解锁后重新尝试加锁。
synchronized
修饰的对象,在执行时会自动进行加锁和解锁操作。根据内存模型的管程中锁的规则
可以得出:前一个线程在临界区对共享变量的修改,对后续进入临界区的线程是可见的。
等待-通知机制预防死锁
在预防死锁发生的占有且等待的条件时,如果通过死循环的方式不停的尝试需要占用的所有资源,并发量大时,对性能会产生恶劣的影响。可通过等待-通知机制来改进。
原理:
- 线程首先获取互斥锁(通过
synchronized
关键字), - 当要求的条件不满足时,释放互斥锁,进入 等待 状态(调用
wait()
方法); - 待要求的条件满足后, 通知 等待的线程(调用
notify()
或notifyAll()
方法); - 被通知的线程需要重新判断条件是否满足(只保证在通知的当下,条件是满足的),重新获取互斥锁。
- 即便通知所有线程(通过调用
notifyAll()
方法),最终也只会有一个线程进入临界区。- 只通知一个线程(通过调用
notify()
方法),可能会导致某些线程永远不会被通知到。
唤醒阻塞的线程破除死锁的不可抢占条件
通过 synchronized
申请资源如果申请不到会阻塞线程。一旦发生死锁,没有机会唤醒阻塞的线程。
Lock接口提供的方法支持阻塞的线程被唤醒,从而有机会释放曾经持有的锁,破除了不可抢占条件,预防了死锁的发生。
-
Lock接口支持中断:
void lockInterruptibly() throws InterruptedException
方法能够触发InterruptedException
中断,从而被唤醒。 -
Lock接口支持超时:
boolean tryLock(long time, TimeUnit unit) throws InterruptedException
方法支持超时,如果一段时间内没有成功获取到锁,被唤醒后可释放曾经持有的锁。 -
Lock接口支持非阻塞地获取锁:
boolean tryLock()
如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。
Lock锁的可见性保证
在 ReentrantLock
内部持有一个 volatile
的成员变量 state
,获取锁和解锁的时候,会读写 state
值,从而保证了解锁相对于后续对这个锁的加锁是可见的。
class X {
private final Lock rtl = new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
- 在执行
value += 1
之前,程序先读写了一次volatile
变量state
; - 在执行
value += 1
之后,又读写了一次volatile 变量 state
;
ReentrantLock
是可重入锁,即拥有该锁的线程,可再次成功的对该锁执行加锁操作。(synchronized
也是可重入锁 )
Lock锁和synchronized的区别
-
Lock
需要手动加锁和解锁,synchronized
代码块运行完毕后会自动解锁; -
ReentrantLock
在实例化时,可设置公平性,减少线程饥饿的概率;Java的调度策略很少发生饥饿,保证公平性会引入额外的开销,建议当程序确实有公平性需求的时候再指定。
-
synchronized
无法支持中断、超时等更便利精细的操作。
用锁的最佳实践
-
永远只在更新对象的成员变量时加锁
-
永远只在访问可变的成员变量时加锁
-
永远不在调用其他对象的方法时加锁
调用其他对象的方法,实在是太不安全了,也许 其他 方法里面有线程
sleep()
的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能。更可怕的是,其他 类的方法可能也会加锁,然后双重加锁就可能导致死锁。
通过 Lock
和 Condition
实现的管程
synchronized
实现的管程只有一个条件,通过 Lock&Condition
实现的管程支持多个条件:
信号量
信号量是保证临界区安全的另一种手段。
PV操作
信号量模型提供三个原子性的方法给外界访问:
-
初始化
设置初始值
-
P操作
即
acquire()
方法。值减 ,如果值小于 则当前线程阻塞。 -
V操作
即
release()
方法。值加 ,如果值大于 则唤醒等待队列中的一个线程,并移除队列。
例:
static int count;
//初始化信号量
static final Semaphore s = new Semaphore(1);
//用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
- 线程1调用
acquire()
方法将信号量减为 ,继续执行; - 线程2调用
acquire()
方法将信号量减为 ,被阻塞; - 线程1调用
release()
方法将信号量加为 ,并唤醒线程2;
多个线程同时访问临界区
与互斥锁不同,信号量可以允许多个线程同时访问同一个临界区。
使用信号量模型实现的对象池:
class ObjPool<T, R> {
final List<T> pool;
// 用信号量实现限流器
final Semaphore sem;
ObjPool(int size, T t){
pool = new Vector<T>(){};
for(int i = 0; i < size; i++){
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象,调用func
R exec(Function<T, R> func) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
// 创建对象池
ObjPool<Long, String> pool = new ObjPool<Long, String>(10, 2);
// 通过对象池获取t,之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
ReadWriteLock锁
ReadWriteLock锁遵循三个基本原则:
- 允许多个线程同时获得读锁;
- 只允许一个线程获得写锁;
- 如果一个线程获得了写锁,禁止其他线程获得锁。
例,一个缓存工具类:
class Cache<K,V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try {
return m.get(key);
}
finally {
r.unlock();
}
}
// 写缓存
V put(K key, V value) {
w.lock();
try {
return m.put(key, v);
}
finally {
w.unlock();
}
}
}
读写锁不支持升级
获取读锁后,在释放读锁之后,能够成功获取写锁,称为锁的升级。读写锁中,如果没有释放读锁就尝试获得写锁,会导致永久等待。
读写锁支持降级
获取写锁后,在释放之前,能够成功获得读锁,称为锁的降级。
StampedLock锁
StampedLock锁支持三种模式:
-
写锁
-
悲观读锁
-
乐观读
该操作是无锁的。因此性能比读写锁好。
例:
class Point {
private int x, y;
final StampedLock sl = new StampedLock();
// 计算到原点的距离
int distanceFromOrigin() {
// 乐观读
long stamp = sl.tryOptimisticRead();
// 读入局部变量,过程中数据可能被修改
int curX = x, curY = y;
// 判断执行读操作期间,
// 是否存在写操作,如果存在,
// 则sl.validate返回false
if (!sl.validate(stamp)) {
// 升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(curX * curX + curY * curY);
}
}
注意事项:
- StampedLock不支持重入。
- StampedLock的悲观读锁、写锁不支持条件变量。
- 线程阻塞在锁上时,如果调用阻塞线程的
interrupt()
方法,会导致CPU占用飙升。如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()
和写锁writeLockInterruptibly()
。