Java中的锁
在Java5.0之前采用的锁机制是用synchronized。Java5.0增加了新的机制:ReentrantLock。与synchronized内置加锁不同,ReentrantLock是一种显示锁。
一、内置锁synchronized
Synchronized的作用主要有三个:(1)确保线程互斥的访问同步代码(2)保证共享变量的修改能够及时可见(3)有效解决重排序问题。从语法上讲,Synchronized总共有三种用法:
- 修饰普通方法
- 修饰静态方法
- 修饰代码块
同步方法的锁就是方法调用所在的对象。普通synchronized方法的锁是this对象,静态的synchronized方法以Class对象作为锁。每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinisic Lock)或监视器锁(Monitor Lock)。Java的内置锁相当于一种互斥锁,最多只有一个线程能持有这种锁。
内置锁是可重入的,如果某个线程试图获得一个已经由它自己持有的锁,那么整个请求就会成功。“重入”意味着获取锁的操作的粒度是线程,而不是调用。重入的一种实现方法是为每个锁关联一个获取计数值和一个所有者线程。
synchronized的实现原理
1、同步代码块
对synchronized修饰的代码块进行反编译,可以看到代码块的指令前后有一个monitorenter和monitorexit指令。每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
我们可以知道Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
2、同步方法
方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
二、显示锁 Lock
Lock是在java5.0开始增加的新的机制。当内置加锁机制不适用时,它可以作为一种可选的高级功能。它提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性。例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限等待下去。Lock相对是一个更灵活的加锁机制通常能提供更好的活跃性或性能。它比内置锁复杂一些:必须在finally块中释放锁。
//Lock接口
public interface Lock{
void Lock();//获取锁
//如果当前线程未被中断,则获取锁
void lockInterruptibly() throws InterruptedException;
//仅在调用时锁为空闲状态才获取锁
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
//Lock接口的标准使用形式
Lock lock = new ReentrantLock();
lock.lock();
try{
//更新对象状态
//捕获异常,并在必要时恢复不变性条件
}finally{
lock.unlock();
}
如果没有finally释放Lock会很危险。出现问题时很难最终到最初发生错误的位置,因为没有记录释放锁的位置和时间,这也是ReentrantLock不能完全替代synchronized的原因。它更危险,在程序的执行控制离开被保护的代码块时,不会自动清除锁。
1、轮询锁与定时锁
可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的内置锁获取模式相比,它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时或可轮询的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有的锁。
在实现具有时间限制的操作时,定时锁同样有用。当在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
2、可中断的锁获取操作
可中断的锁获取操作同样能在可取消的操作中使用加锁。例如请求内置锁,这些不可中断的阻塞机制将使得实现可取消的任务变得复杂,lockInterruptibly方法能够在获得锁的同时保持对中断的响应,并且由于它包含在Lock中,因此无须创建其他类型的不可中断阻塞机制。
3、非块结构的加锁
内置锁的获取和释放都是基于代码块。但有时候我们可以通过降低锁的力度来提高代码的可伸缩性。例如锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过类似的原则来降低链表中锁的力度,即为每个链表节点使用一个独立的锁,使不同的线程能独立地对链表的不同部分进行操作。
4、公平性
ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁或者一个公平的锁。公平的锁上,线程按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许插队:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。
当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大地降低性能。实际情况中,统计上的公平性保证—确保被阻塞的线程能最终获得锁,通常就够用了,而且实际开销也小得多。除非有些算法需要依赖公平排队算法确保正确性,但是这种情况不常见。
与默认的ReentrantLock一样,内置加锁并不会提供确定的公平性保证,但在大多数情况下,在锁实现上实现统计上的公平性保证已经足够了。
三、在synchronized和ReentrantLock的比较
- ReentrantLock在加锁和内存上提供的语义与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待,可中断的锁等待、公平性,以及实现非块结构的加锁。
- ReentrantLock在性能上优于内置锁,Java5优势更明显,java6则略有胜出(内置锁优化了)。
- ReentrantLock的危险性比同步机制要高。
- 在5.0中,内置锁在线程转储中能给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。ReentrantLock的非块结构特性意味着,获取锁的操作不能与特定的栈帧关联起来,而内置锁可以。JVM不知道哪些线程持有ReentrantLock,在调试时没有帮助作用。
四、读写锁ReadWriteLock**
ReentrantLock通常是一种过于强硬的加锁规则,会不必要地限制并发性。他也限制了读读冲突,但许多情况下,数据结构上的操作大部分都是读操作。可以放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,提升程序性能。
读写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。在多处理器系统上被频繁读取的数据结构,它能提高性能,当其他情况下,性能比独占锁差一点,因为复杂性更高