J.U.C 简介
Java.util.concurrent
是在并发编程中比较常用的东西类,里面包括许多用来在并发场景中运用的组件。比方线程池、堵塞行列、计时器、同步器、并发调集等等。并发包的作者是大名鼎鼎的 Doug Lea。
Lock
Lock
在 J.U.C 中是最核心的组件,锁最重要的特性便是处理并发安全问题。为什么要以 Lock
作为切入点呢?
假设你有看过 J.U.C 包中的一切组件,一定会发现绝大部分的组件都有用到了 Lock
。所以经过 Lock
作为切入点使得在后续的学习进程中会更加轻松。
Lock
简介
在 Lock
接口呈现之前,Java
中的应用程序对于多线程的并发安全处理只能根据 synchronized
关键字来处理。可是 synchronized
在有些场景中会存在一些短板,也便是它并不适合于一切的并发场景。可是在 Java5
以后,Lock
的呈现可以处理 synchronized
在某些场景中的短板,它比 synchronized
更加灵敏。
Lock
的完结
Lock
本质上是一个接口,它界说了开释锁和取得锁的笼统办法,界说成接口就意味着它界说了锁的一个标准规范,也一起意味着锁的不同完结。
完结 Lock
接口的类有许多,以下为几个常见的锁完结
-
ReentrantLock
:表明重入锁,它是仅有一个完结了Lock
接口的类。重入锁指的是线程在取得锁之后,再次获取该锁不需求堵塞,而是直接关联一次计数器增加重入次数 -
ReentrantReadWriteLock
:重入读写锁,它完结了ReadWriteLock
接口,在这个类中维护了两个锁,一个是ReadLock
,一个是WriteLock
,他们都别离完结了Lock
接口。读写锁是一种适合读多写少的场景下处理线程安全问题的东西,基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也便是说涉及到影响数据改变的操作都会存在互斥。 -
StampedLock
:stampedLock
是JDK8
引进的新的锁机制,可以简单认为是读写锁的一个改善版别,读写锁虽然经过分离读和写的功用使得读和读之间可以彻底并发,可是读和写是有冲突的,假设大量的读线程存在,或许会引起写线程的饥饿。stampedLock
是一种达观的读策略,使得达观锁彻底不会堵塞写线程
Lock
的类联系图
Lock
有许多的锁的完结,可是直观的完结是 ReentrantLock
重入锁
常用API
void lock() // 假设锁可用就取得锁,假设锁不可用就堵塞直到锁开释
void lockInterruptibly() // 和lock()办法相似, 但堵塞的线程可中止,抛出java.lang.InterruptedException 反常
boolean tryLock() // 非堵塞获取锁;尝试获取锁,假设成功返回 true
boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁办法
void unlock() // 开释锁
ReentrantLock
重入锁
重入锁,表明支持从头进入的锁,也便是说,假设当时线程 t1 经过调用 lock
办法获取了锁之后,再次调用 lock
,是不会再堵塞去获取锁的,直接增加重试次数就行了。synchronized
和 ReentrantLock
都是可重入锁。那为什么锁会存在重入的特性?假设在下面这类的场景中,存在多个加锁的办法的相互调用,其实便是一种重入特性的场景。
重入锁的规划意图
比方调用 demo 办法取得了当时的目标锁,然后在这个办法中再去调用demo2,demo2 中的存在同一个实例锁,这个时分当时线程会由于无法取得demo2 的目标锁而堵塞,就会发生死锁。重入锁的规划意图是防止线程的死锁。
public class ReentrantDemo {
public synchronized void demo() {
System.out.println("begin:demo");
demo2();
}
public void demo2() {
System.out.println("begin:demo1");
synchronized (this) {
}
}
public static void main(String[] args) {
ReentrantDemo rd = new ReentrantDemo();
new Thread(rd::demo).start();
}
}
ReentrantLock
的运用事例
public class AtomicDemo {
private static int count = 0;
static Lock lock = new ReentrantLock();
public static void inc() {
lock.lock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
lock.unlock();
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
AtomicDemo.inc();
}).start();
;
}
Thread.sleep(3000);
System.out.println("result:" + count);
}
}
ReentrantReadWriteLock
咱们曾经了解的锁,基本都是排他锁,也便是这些锁在同一时间只答应一个线程进行拜访,而读写所在同一时间可以答应多个线程拜访,可是在写线程拜访时,一切的读线程和其他写线程都会被堵塞。读写锁维护了一对锁,一个读锁、一个写锁; 一般情况下,读写锁的功用都会比排它锁好,由于大多数场景读是多于写的。在读多于写的情况下,读写锁可以提供比排它锁更好的并发性和吞吐量。
public class LockDemo {
static Map<String, Object> cacheMap = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock read = rwl.readLock();
static Lock write = rwl.writeLock();
public static final Object get(String key) {
System.out.println("开端读取数据");
read.lock(); //读锁
try {
return cacheMap.get(key);
} finally {
read.unlock();
}
}
public static final Object put(String key, Object value) {
write.lock();
System.out.println("开端写数据");
try {
return cacheMap.put(key, value);
} finally {
write.unlock();
}
}
}
在这个事例中,经过 hashmap
来模拟了一个内存缓存,然后运用读写所来确保这个内存缓存的线程安全性。当履行读操作的时分,需求获取读锁,在并发拜访的时分,读锁不会被堵塞,由于读操作不会影响履行结果。
在履行写操作是,线程必需求获取写锁,当已经有线程持有写锁的情况下,当时线程会被堵塞,只有当写锁开释以后,其他读写操作才干继续履行。运用读写锁提升读操作的并发性,也确保每次写操作对一切的读写操作的可见性。
- 读锁与读锁可以同享
- 读锁与写锁不可以同享(排他)
- 写锁与写锁不可以同享(排他)
ReentrantLock 的完结原理
咱们知道锁的基本原理是,根据将多线程并行任务经过某一种机制完结线程的串行履行,从而到达线程安全性的意图。在 synchronized
中,咱们剖析了偏向锁、轻量级锁、达观锁。根据达观锁以及自旋锁来优化了 synchronized
的加锁开支,一起在重量级锁阶段,经过线程的堵塞以及唤醒来到达线程竞赛和同步的意图。那么在 ReentrantLock
中,也一定会存在这样的需求去处理的问题。便是在多线程竞赛重入锁时,竞赛失利的线程是如何完结堵塞以及被唤醒的呢?
AQS
是什么
在 Lock
中,用到了一个同步行列 AQS
,全称 AbstractQueuedSynchronizer
,它是一个同步东西也是 Lock
用来完结线程同步的核心组件。假设你搞懂了 AQS
,那么 J.U.C
中绝大部分的东西都能轻松掌握。
AQS
的两种功用
从运用层面来说,AQS
的功用分为两种:独占和同享
独占锁,每次只能有一个线程持有锁,比方前面给咱们演示的 ReentrantLock
便是
以独占方法完结的互斥锁
同享锁,答应多个线程一起获取锁,并发拜访同享资源,比方 ReentrantReadWriteLock
AQS
的内部完结
AQS
行列内部维护的是一个 FIFO
的双向链表,这种结构的特点是每个数据结构都有两个指针,别离指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开端很方便的拜访前驱和后继。每个 Node
其实是由线程封装,当线程争抢锁失利后会封装成 Node
加入到 ASQ
行列中去;当获取锁的线程开释锁以后,会从行列中唤醒一个堵塞的节点(线程)。
Node
的组成
开释锁以及增加线程对于行列的改变
当呈现锁竞赛以及开释锁的时分,AQS
同步行列中的节点会发生改变,首先看一下增加节点的场景。
这里会涉及到两个改变
- 新的线程封装成
Node
节点追加到同步行列中,设置prev
节点以及修正当时节点的前置节点的next
节点指向自己 - 经过
CAS
讲tail
从头指向新的尾部节点
head
节点表明获取锁成功的节点,当头结点在开释同步状况时,会唤醒后继节点,假设后继节点取得锁成功,会把自己设置为头结点,节点的改变进程如下
这个进程也是涉及到两个改变
- 修正
head
节点指向下一个取得锁的节点 - 新的取得锁的节点,将
prev
的指针指向null
设置 head
节点不需求用 CAS
,原因是设置 head
节点是由取得锁的线程来完结的,而同步锁只能由一个线程取得,所以不需求 CAS
确保,只需求把 head
节点设置为原首节点的后继节点,而且断开原 head
节点的 next
引证即可
ReentrantLock
的源码剖析
以 ReentrantLock
作为切入点,来看看在这个场景中是如何运用 AQS
来完结线程的同步的
ReentrantLock
的时序图
调用 ReentrantLock
中的 lock()
办法,源码的调用进程我运用了时序图来展现。
ReentrantLock.lock()
这个是 reentrantLock 获取锁的入口
public void lock() {
sync.lock();
}
sync
实际上是一个笼统的静态内部类,它承继了 AQS
来完结重入锁的逻辑,咱们前面说过 AQS
是一个同步行列,它可以完结线程的堵塞以及唤醒,但它并不具有业务功用,所以在不同的同步场景中,会承继 AQS
来完结对应场景的功用,Sync
有两个具体的完结类,别离是:
-
NofairSync
:表明可以存在抢占锁的功用,也便是说不论当时行列上是否存在其他线程等待,新线程都有时机抢占锁 -
FailSync
: 表明一切线程严厉依照FIFO
来获取锁
NofairSync.lock
以非公正锁为例,来看看 lock
中的完结
- 非公正锁和公正锁最大的区别在于,在非公正锁中我抢占锁的逻辑是,不论有没有线程排队,我先上来
cas
去抢占一下 -
CAS
成功,就表明成功取得了锁 -
CAS
失利,调用acquire(1)
走锁竞赛逻辑
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
CAS 的完结原理
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
经过 cas
达观锁的方法来做比较并替换,这段代码的意思是,假设当时内存中的 state
的值和预期值 expect
相等,则替换为 update
。更新成功返回 true
,否则返回 false
。
这个操作是原子的,不会呈现线程安全问题,这里面涉及到Unsafe这个类的操作,以及涉及到 state
这个属性的意义。
state
是 AQS
中的一个属性,它在不同的完结中所表达的意义不一样,对于重入锁的完结来说,表明一个同步状况。它有两个意义的表明
- 当 state=0 时,表明无锁状况
- 当 state>0 时,表明已经有线程取得了锁,也便是 state=1,可是由于ReentrantLock 答应重入,所以同一个线程多次取得同步锁的时分,state 会递加,比方重入 5 次,那么 state=5。而在开释锁的时分,同样需求开释 5 次直到 state=0其他线程才有资历取得锁