线程同步机制
线程同步机制是一套用于协调线程间的数据访问及活动的机制。该机制用于保障线程安全及实现这些线程的共同目标。
java平台提供的线程同步机制:
- 锁
- volatile关键字
- final关键字
- static关键字
- 其他(如:Object.wait()/Object.notify()等)
锁机制
锁机制 :将多线程并发访问共享数据转换为串行访问,一个共享数据每次只能被一个线程访问(获得锁),该线程访问结束后(释放锁)其他线程才能对其访问。
锁的获得 : 一个线程在访问数据前必须申请相应的锁。
锁的持有线程 : 一个线程获得某个锁。
一个锁一次只能被一个线程持有
临界区 :锁的持有线程在获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区。共享数据只能在临界区内进行访问,临界区一次只能被一个线程执行。
排它锁/互斥锁 :锁具有排他性,一次只能被一个线程持有,这种锁被称为排它或锁互斥锁。
java虚拟机对锁实现方式的分类:
- 内部锁:通过关键字synchronized实现。
- 显示锁:通过java.concurrent.locks.Lock的接口实现类实现。
锁的作用
保护共享数据实现线程安全:
- 保障原子性:通过锁的互斥保障原子性,临界区代码一次只能被一个线程执行,没有其他线程访问,使得临界区代码执行操作具有不可分割的特性。
- 可见性保障:
锁的几个概念
可重入性 : 一个线程在持有一个锁的时候还能够继续成功申请该锁,就称该锁具有可重入性,反之则称为非可重入性。
锁的粒度 : 一个锁实例可以保护一个或者多个共享数据,一个实例所保护的共享数据的数量大小就被称为该锁的粒度,锁保护的共享数据越大,我们就称该锁的粒度约粗,反之则称粒度细。
锁的开销
锁的开销包括几个:
- 锁的申请和释放所产生的开销(主要是时间开销)
- 锁的上下文切换(主要是时间开销)
- 锁的不正确使用会导致一些线程活性故障
- 锁泄露 :锁泄露指一个线程获得该锁之后,由于程序的错误、缺陷导致该锁一直无法被释放而其他线程一直无法获得该锁的现象。因此,锁泄露会导致同步在该锁上的所有线程都无法进展。
- 死锁锁死等线程其他活性故障。
内部锁:Synchronized关键字
内部锁是通过Synchronized关键字实现的,Synchronized关键字可以用开修饰方法及代码块。
- Synchronized关键字修饰的方法被称为同步方法
- Synchronized关键字修饰的静态方法被称为同步静态方法
- Synchronized关键字修饰的实例方法被称为同步实例方法
- Synchronized关键字修饰代码块被称为同步块
同步方法的整个方法体就是一个临界区
同步块:Synchronized关键字修饰代码
Synchronized(锁句柄){
//在此代码块访问共享数据
}
锁句柄是一个对象的引用。锁句柄可以直接填写this关键字表示当前对象。锁句柄对应的监视器被称为相应同步块的引导锁,相应的我们称呼相应的同步块为该锁引导的同步块。
锁句柄通常采用final修饰(private final)。这是因为
锁句柄的值一旦改变,会导致同一个代码块的多个线程实际上使用不同的锁,而导致竞态。
同步静态方法相当于当前类为引导锁的同步块。
public class SynchronizedMethodExample {
public static sysnchronized void staticMethod(){
//在此访问共享数据
}
//...
}
//相当于
public class SynchronizedMethodExample {
public static void staticMethod(){
sysnchronized(SynchronizedMethodExample.class){
//在此访问共享数据
}
}
//...
}
线程在执行临界区代码的时候必须持有该临界区的引导锁。一个线程执行到
同步代码块石必须申请该同步块的引导锁,只有申请成功该锁的线程才能够执行相的应临界区。一旦执行完临界区代码,引导该临界区的锁就会被自动释放。整个内部锁申请和释放的过程都是由java虚拟机负责实施的,所以synchronized实现的锁被称为内部锁。
内部锁不会导致锁泄露,java编译器在将同步代码块编译成字节码的时候,对临界区可能抛出的异常(未被捕获)进行了处理,所以即使临界区代码抛出异常也不会妨碍内部锁的释放。
内部锁的调度
Java虚拟机会给每个内部锁分配一个入口集(Entry Set),用于记录等待获取锁的线程。申请锁失败的线程会被存入入口集中等待再次申请锁的机会(这些线程处于BLOCKED状态,被称为等待线程)。
当锁被释放时,入口集中的一个线程被唤醒,得到再次申请锁的机会,仅仅是机会!因为内部锁的机制仅支持 非公平调度,所以可能被其他新的活跃线程抢占这个释放锁。
显示锁
jdk1.5引入的排他锁,其作用于内部锁相同, 但是它提供了一些内部锁所不具备的特性。
显示锁是java.util.concurrent.locks.Lock接口的实例。
摘要方法
void lock() 获取锁
void lockInterruptibly() 如果当前线程未被中断,则获取锁
newCondition() 返回绑定到此Lock实例的新Conditon实例
tryLock() 仅在调试时锁为空闲状态才获取锁
tryLock(long time, TimeUnit uinit) 如果说在给定的时间空闲,并且当前线程未被中断,则获取锁
unlock() 释放锁
显示锁的使用
//一个Lock接口实例就是一个显示锁的对象
private final Lock lock = ...; //创建Lock接口实例
lock.lock(); //申请锁lock
try{
//在此对共享数据访问
}finally{
//总是在finally块中释放锁,避免锁泄露
lock.unlock(); //释放锁
}
- 创建Lock接口实例。如果没有特别要求,可以创建Lock接口的默认实现类ReentrantLock的实例作为显示是使用。
- 在访问共享数据前申请相应的显示锁。Lock.lock()
- 在临界区访问数据。Lock.lock()调用之后与Lock.unlock()调用之前的代码块区域。
- 共享数据访问之后释放锁。
//使用显示锁实现循环递增的序列生成器
public class LockbasedCircularSeqGenerator implements CircularSeqGenerator {
private short sequence = -1;
private final Lock lock = new ReentrantLock();
@Override
public short nextSequence() {
lock.lock();
try {
if (sequence >= 999) {
sequence = 0;
} else {
sequence++;
}
return sequence;
} finally {
lock.unlock();
}
}
}
显示锁的调度
显示锁默认使用非公平策略调度。因为公平锁的开销比非公平锁的开销要大。
公平锁为保证公平增加了线程暂停和唤醒的可能性,导致了上下文切换的消耗要更大。所以公平锁适合用于锁被持有时间相对较长或线程申请锁时间相对较长的情况。总体来说公平锁的消耗比非公平锁消耗要大。
显示锁和内部锁应用区别
-
内部锁简单易用,且不会导致锁泄露;显示锁容易被错用而导致锁泄露(缺少释放锁的动作)。
-
内部锁是基于代码块的锁,灵活性较差,要么使用,用么不使用;而显示锁是基于对象的锁,灵活性强,比如可以在一个方法内申请锁,在另一个方法释放锁,而内部锁是无法做到的。
-
调度方面,内部是只支持非公平调度;显示锁两者都支持。
显示锁的其他特性。
如果内部锁的持有线程一直不释放该锁(通常代码错误导致),同步在该锁的所以线程都会被暂停而使任务无法进展。显示锁可以避免此问题,使用显示锁的tryLock() 方法,锁处于空闲状态返回true,否则返回false
Lock lock = ...;
if(lock.tryLock()){ //也可给tryLock()指定时间
try{
//在此访问共享数据
}finally{
lock.unlock();
}
}else{
/执行其他任务
}
读写锁
是一种改进型的排它锁,也被称为共享/排他锁。读写锁允许多个线程同时读取共享变量,当一次只能允许一个线程对共享变量进行更新。
任何线程读取共享变量的时候其他线程无法对该共享变量进行更新,一个线程更新共享变量的时候其他线程都无法访问该变量。
读写锁是java.util.concurrent.locks.ReadWriteLock 接口的实例,默认实现类是java.util.concurrent.locks.ReentrantReadWriteLock。
ReadWriteLock接口定义了两个方法:readLock() 和 writeLock(),两个方法返回值都是lock类型
读写锁的两种角色
-
读锁:读线程访问共享变量时必须持有相应的读锁。且读锁可以被多个线程持有。
-
写锁:写锁是排他锁,一个线程持有写锁,其他成线程无法获得相应的写锁或读锁。
多个读线程提高了并发性,而写锁保障了写线程能够独占的方式安全的更新共享变量。
读写锁的使用
public class ReadWriteLockUsage {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.write();
//读线程执行
public void reader(){
readLock.lock(); //申请读锁
try{
//读取共享变量
}finally{
//释放锁避免泄露
readLock.unlock();
}
}
//写线程执行
public void writer(){
writeLock.lock(); //申请写锁
try{
//访问共享变量
}finally{
//释放锁避免泄露
writeLock.unlock();
}
}
}
读写锁应用场景
- 读操作比写操作频繁得多
- 读线程持有锁的时间比较长
ReetrantReadWriteLock 说实现的读写锁是可重入锁;且支持锁的降级,即一个线程持有写锁的情况下可以获得相应的写锁。
锁的适用场景
- check-then-act操作:一个线程读取共享数据,并在此基础上决定下一步操作是什么。
- read-modify-write操作:一个线程读取共享数据并在此基础上更新该数据。
- 多个线程对多个共享数据更新:如果这些共享数据之间存在关联关系,那么为力保证操作的原子性,可以考虑使用锁。