最常见的秒杀系统,解决思路就是从前端、后台服务、数据库层层去掉负载,以达到平衡
锁作为并发共享数据,保证一致性的工具,在JAVA平台有多种实现(如 synchronized 和 ReentrantLock等等 ) 。这些已经写好提供的锁为我们开发提供了便利,但是锁的具体性质以及类型却很少被提及。本系列文章将分析JAVA下常见的锁名称以及特性,为大家答疑解惑。
public class Thread1 implements Runnable { private String flag = "start"; private String control = ""; public void run() { // TODO Auto-generated method stub int i = 0; while (true) { if (flag.equals("start")) { i++; System.out.println("The thread1 is running" + i); } else if (flag.equals("wait")) { try { System.out.println("===wait==="); synchronized (control) { control.wait(); } } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } } public void wait1() { this.flag = "wait"; } public void start1() { this.flag = "start"; if (flag.equals("start")) { synchronized (control) { control.notifyAll(); } } } }
看调用
public static void main(String[] args) { // TODO Auto-generated method stub Thread1 th1 = new Thread1(); Thread t1 = new Thread(th1); t1.start(); try { Thread.sleep(20); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } //thread线程暂停 th1.wait1(); try { Thread.sleep(2000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } //thread线程继续运行 th1.start1(); //th1.wait1(); //th1.start1(); }
ReentrantLock和synchronized都是可重入锁
可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。
public class ReentrantTest implements Runnable {
public synchronized void get() {
System.out.println(Thread.currentThread().getName());
set();
}
public synchronized void set() {
System.out.println(Thread.currentThread().getName());
}
public void run() {
get();
}
public static void main(String[] args) {
ReentrantTest rt = new ReentrantTest();
for(;;){
new Thread(rt).start();
}
输出
Thread-8492
Thread-8492
Thread-8494
Thread-8494
Thread-8495
Thread-8495
Thread-8493
Thread-8493
set()和get()同时输出了线程名称,表明即使递归使用synchronized也没有发生死锁,证明其是可重入的。
不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。
package com.thread; import java.util.concurrent.atomic.AtomicReference; public class UnreentrantLock { private AtomicReference<Thread> owner = new AtomicReference<Thread>();//记录当前锁的持有线程对象 public void lock() {//加锁 Thread current = Thread.currentThread();//获取当前线程对象 for (; ; ) {//自旋(被当前线程或其他线程持有锁,就会循环) for 的三种用法,class、增强型循环、无线循环 if (owner.compareAndSet(null, current)) {//只有锁可用即为null,才能设置当前线程为锁持有对象,并返回true return; } } } public void unlock() {//解锁 Thread current = Thread.currentThread();//获取当前线程对象 owner.compareAndSet(current, null);//设置锁的持有对象为null } }
使用原子引用来存放线程,同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的。
实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。稍微改一下,把它变成一个可重入锁:
package com.thread; import java.util.concurrent.atomic.AtomicReference; public class UnreentrantLock { private AtomicReference<Thread> owner = new AtomicReference<Thread>();//记录当前锁的持有线程对象 private int state = 0;//记录重入次数 public void lock() {//加锁 Thread current = Thread.currentThread();//获取当前线程对象 if (owner.compareAndSet(null, current)) {//当前锁可用 state = 1;//状态置为1 return; } else { if (current == owner.get()) {//如果当前线程持有锁 state++;//重入次数加1 return; } for (; ; ) {//被其他线程持有就会继续循环 if (owner.compareAndSet(null, current)) {//只有锁可用即为null,才能设置当前线程为锁持有对象,并返回true return; } } } } public void unlock() {//解锁 Thread current = Thread.currentThread();//获取当前线程对象 if (current == owner.get()) {//如果当前线程持有锁 if (state > 0) {//重入次数大于0 state--;//重入次数减1 } else { owner.compareAndSet(current, null);//设置锁的持有对象为null } } } }
在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。
ReentrantLock原理
- 原子状态:原子状态有 CAS(compareAndSetState) 操作来存储当前锁的状态,判断锁是否有其他线程持有。
- 等待队列:所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统才能够从等待队列中唤醒一个线程,继续工作。详见:队列同步器——AQS
- 阻塞原语 park() 和 unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。关于阻塞原语,详见:线程阻塞工具类——LockSupport
ReentrantLock的几个重要方法整理如下:
- lock():获得锁,如果锁被占用,进入等待。
- lockInterruptibly():获得锁,但优先响应中断。
- tryLock():尝试获得锁,如果成功,立即放回 true,反之失败返回 false。该方法不会进行等待,立即返回。
- tryLock(long time, TimeUnit unit):在给定的时间内尝试获得锁。
unLock():释放锁。
一、何为重进入(重入)?
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决以下两个问题:
-
- 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
- 锁的最终释放。线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其它线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。
ReentrantLock中非公平的可重入锁实现: // 非公平方式获取锁,用于tryLock()方法 final boolean nonfairTryAcquire(int acquires) { //当前线程 final Thread current = Thread.currentThread(); // 继承至AbstractQueuedSynchronizer的方法 int c = getState();//获取锁状态值 //没有线程正在竞争该锁 if (c == 0) { // 继承至AbstractQueuedSynchronizer的方法 if (compareAndSetState(0, acquires)) {//若state为0则将state修改为acquires的值,状态0表示锁没有被占用 setExclusiveOwnerThread(current);// 设置当前线程独占 return true;// 成功 } } else if (current == getExclusiveOwnerThread()) {// 当前线程拥有该锁 int nextc = c + acquires;// 增加重入次数 if (nextc < 0) // overflow(计数值小于0,则抛出异常) throw new Error("Maximum lock count exceeded"); // 继承至AbstractQueuedSynchronizer的方法 setState(nextc);//设置锁状态值 return true;// 成功 } return false;// 失败 }
acquireQueued 方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程,来决定获取操作是否成功,如果获取锁的线程再次请求,则将同步状态值进行增加并返回 true,表示获取同步状态成功。
成功获取锁的线程再次获取锁,只是增加了同步状态值,也就是要求 ReentrantLock 在释放同步状态时减少同步状态值,释放锁源码如下:
public void unlock() { sync.release(1); } public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
如果锁被获取 n 次,那么前 (n-1) 次 tryRelease(int releases) 方法必须返回 false,只有同步状态完全释放了,才能返回 true。该方法将同步状态是否为 0 作为最终释放的条件,当同步状态为 0 时,将占有线程设置为 null,并返回 true,表示释放成功。
通过对获取与释放的分析,就可以解释,以上两个例子中出现的两个问题:为什么 ReentrantLock 锁能够支持一个线程对资源的重复加锁?为什么公平锁例子中出现,公平锁线程是不断切换的,而非公平锁出现同一线程连续获取锁的情况?
- 为什么支持重复加锁?因为源码中用变量 c 来保存当前锁被获取了多少次,故在释放时,对 c 变量进行减操作,只有 c 变量为 0 时,才算锁的最终释放。所以可以 lock 多次,同时 unlock 也必须与 lock 同样的次数。
- 为什么非公平锁出现同一线程连续获取锁的情况?tryAcquire 方法中增加了再次获取同步状态的处理逻辑;
二、为什么使用可重入锁?
ReentrantLock 是一个可重入的互斥(/独占)锁,又称为“独占锁”。
ReentrantLock通过自定义队列同步器(AQS-AbstractQueuedSychronized,是实现锁的关键)来实现锁的获取与释放。
其可以完全替代 synchronized 关键字。JDK 5.0 早期版本,其性能远好于 synchronized,但 JDK 6.0 开始,JDK 对 synchronized 做了大量的优化,使得两者差距并不大。
“独占”,就是在同一时刻只能有一个线程获取到锁,而其它获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。
“可重入”,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
该锁还支持获取锁时的公平和非公平性选择。“公平”是指“不同的线程获取锁的机制是公平的”,而“不公平”是指“不同的线程获取锁的机制是非公平的”。
1 中断响应(lockInterruptibly)
对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种情况,获得这把锁继续执行,或者线程就保持等待。
而使用重入锁,提供了另一种可能,这就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的需求。
下面的例子中,产生了死锁,但得益于锁中断,最终解决了这个死锁:
public class IntLock implements Runnable{ public static ReentrantLock lock1 = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); int lock; /** * 控制加锁顺序,产生死锁 */ public IntLock(int lock) { this.lock = lock; } public void run() { try { if (lock == 1) { lock1.lockInterruptibly(); // 如果当前线程未被 中断,则获取锁。 try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } lock2.lockInterruptibly(); System.out.println(Thread.currentThread().getName()+",执行完毕!"); } else { lock2.lockInterruptibly(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } lock1.lockInterruptibly(); System.out.println(Thread.currentThread().getName()+",执行完毕!"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { // 查询当前线程是否保持此锁。 if (lock1.isHeldByCurrentThread()) { lock1.unlock(); } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getName() + ",退出。"); } } public static void main(String[] args) throws InterruptedException { IntLock intLock1 = new IntLock(1); IntLock intLock2 = new IntLock(2); Thread thread1 = new Thread(intLock1, "线程1"); Thread thread2 = new Thread(intLock2, "线程2"); thread1.start(); thread2.start(); Thread.sleep(1000); thread2.interrupt(); // 中断线程2 } }
上述例子中,线程 thread1 和 thread2 启动后,thread1 先占用 lock1,再占用 lock2;thread2 反之,先占 lock2,后占 lock1。这便形成 thread1 和 thread2 之间的相互等待。
代码 56 行,main 线程处于休眠(sleep)状态,两线程此时处于死锁的状态,代码 57 行 thread2 被中断(interrupt),故 thread2 会放弃对 lock1 的申请,同时释放已获得的 lock2。这个操作导致 thread1 顺利获得 lock2,从而继续执行下去。
执行代码,输出如下:
2锁申请等待限时(tryLock)
除了等待外部通知(中断操作 interrupt )之外,限时等待也可以做到避免死锁。
通常,无法判断为什么一个线程迟迟拿不到锁。也许是因为产生了死锁,也许是产生了饥饿。但如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。可以使用 tryLock() 方法进行一次限时的等待。
public class TimeLock implements Runnable{ public static ReentrantLock lock = new ReentrantLock(); public void run() { try { if (lock.tryLock(5, TimeUnit.SECONDS)) { Thread.sleep(6 * 1000); }else { System.out.println(Thread.currentThread().getName()+" get Lock Failed"); } } catch (InterruptedException e) { e.printStackTrace(); }finally { // 查询当前线程是否保持此锁。 if (lock.isHeldByCurrentThread()) { System.out.println(Thread.currentThread().getName()+" release lock"); lock.unlock(); } } } /** * 在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法再5秒的等待时间内获得锁,因此请求锁会失败。 */ public static void main(String[] args) { TimeLock timeLock = new TimeLock(); Thread t1 = new Thread(timeLock, "线程1"); Thread t2 = new Thread(timeLock, "线程2"); t1.start(); t2.start(); } }
上述例子中,由于占用锁的线程会持有锁长达 6 秒,故另一个线程无法在 5 秒的等待时间内获得锁,因此,请求锁失败。
ReentrantLock.tryLock()方法也可以不带参数直接运行。这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁成功,立即返回 true。否则,申请失败,立即返回 false,当前线程不会进行等待。这种模式不会引起线程等待,因此也不会产生死锁。
3 公平锁
·默认情况下,锁的申请都是非公平的。也就是说,如果线程 1 与线程 2,都申请获得锁 A,那么谁获得锁不是一定的,是由系统在等待队列中随机挑选的。这就好比,买票的人不排队,售票姐姐只能随机挑一个人卖给他,这显然是不公平的。而公平锁,它会按照时间的先后顺序,保证先到先得。公平锁的特点是:不会产生饥饿现象。
重入锁允许对其公平性进行设置。构造函数如下:
public ReentrantLock(boolean fair)
public class FairLock implements Runnable{ public static ReentrantLock fairLock = new ReentrantLock(true); public void run() { while (true) { try { fairLock.lock(); System.out.println(Thread.currentThread().getName()+",获得锁!"); }finally { fairLock.unlock(); } } } public static void main(String[] args) { FairLock fairLock = new FairLock(); Thread t1 = new Thread(fairLock, "线程1"); Thread t2 = new Thread(fairLock, "线程2"); t1.start();t2.start(); } }
测试结果:
1.当参数设置为 true 时:线程1 和 线程2 交替进行 公平竞争 交替打印
线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁!
2.当参数设置为 false 时: 此时可以看到线程1 可以持续拿到锁 等线程1 执行完后 线程2 才可以拿到线程 然后多次执行 ; 这就是使用 可重入锁后 是非公平机制 线程可以优先多次拿到执行权
线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程2,获得锁! 线程2,获得锁! 线程2,获得锁! 线程2,获得锁!
修改重入锁是否公平,观察输出结果,如果公平,输出结果始终为两个线程交替的获得锁,如果是非公平,输出结果为一个线程占用锁很长时间,然后才会释放锁,另个线程才能执行。
ReenTrantLock可重入锁(和synchronized的区别)总结
可重入性:
从名字上理解,ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程没进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
锁的实现:
Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。
性能的区别:
在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。
功能区别:
便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized
ReenTrantLock独有的能力:
1. ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
2. ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
3. ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
ReenTrantLock实现的原理:
在网上看到相关的源码分析,本来这块应该是本文的核心,但是感觉比较复杂就不一一详解了,简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
什么情况下使用ReenTrantLock:
答案是,如果你需要实现ReenTrantLock的三个独有功能时。
公平与非公平唯一的区别是判断条件中多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回了true,则表示有线程比当前线程更早地请求获取锁,所以需要等待前驱线程获取并释放锁后才能继续获取该锁。
但是非公平锁是默认实现:非公平性锁可能使线程“饥饿”,但是极少的线程切换,可以保证其更大的吞吐量。而公平性锁,保证了锁的获取按照FIFO原则,代价是进行大量的线程切换。
synchronized可重入性
同一线程在调用自己类中其他synchronized方法/块或调用父类的synchronized方法/块都不会阻碍该线程的执行,就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。