目录
ReentrantLock 和 Synchronized 区别
J.U.C 简介
Java.util.concurrent是在并发编程中比较常用的工具类,里面包含很多用来在并发 场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。并 发包的作者是大名鼎鼎的Doug Lea
Lock
1 Lock 的实现
Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意 味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。实现Lock接口的类有很多,以下为几个常见的锁实现
ReentrantLock:表示重入锁,它是唯一一个实现了Lock接口的类。重入锁指的是 线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入 次数
ReentrantReadWriteLock:重入读写锁,它实现了ReadWriteLock接口,在这个 类中维护了两个锁,一个是ReadLock,一个是WriteLock,他们都分别实现了Lock 接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则 是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的 操作都会存在互斥。
StampedLock: stampedLock是JDK8引入的新的锁机制,可以简单认为是读写 锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全 并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。 stampedLock是一种乐观的读策略,使得乐观锁完全不会阻塞写线程 。
2 Lock 的类关系图
Lock有很多的锁的实现,但是直观的实现是ReentrantLock重入锁。
void lock() // 如果锁可用就 获得锁,如果锁不可用就阻塞 直到锁释放
void lockInterruptibly() // 和 lock()方法相似, 但阻塞的线 程 可 中 断 , 抛 出 java.lang.InterruptedExcepti on异常
boolean tryLock() // 非阻塞 获取锁;尝试获取锁,如果成功 返回true
boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁方法 void unlock() // 释放锁
3 重入锁 Reentrantlock
重入锁,表示支持重新进入的锁,也就是说,如果当前线程t1通过调用lock方 法获取了锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数 就行了。synchronized和ReentrantLock都是可重入锁。
为什么要有重入锁?1 提高性能。2 避免死锁
synchronized和reentrantlock 都是支持重入的
public class SynchronizedDemo {
public synchronized void funcation1(){//获取同步互斥锁
System.out.println("funcation1");
funcation2();
}
public void funcation2(){
//如果不是重入锁则等待获取同步互斥锁
synchronized (this){//增加重入次数
System.out.println("funcation2");
}//减少重入次数
}
public static void main(String[] args) {
new SynchronizedDemo().funcation1();
}
}
如上例 如果synchronized不是重入锁,则funcation1(),funcation2()都会一直处于阻塞状态, 则会发生死锁。
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);
}
}
4 ReentrantReadWriteLock
我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进 行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有 的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁; 一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读 多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
如下代码使用读写锁 帮助HashMap 实现put()方法的线程安全
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来模拟了一个内存缓存,然后使用读写所来保证这 个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时 候,读锁不会被阻塞,因为读操作不会影响执行结果。 在执行写操作是,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线 程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升 读操作的并发性,也保证每次写操作对所有的读写操作的可见性
⚫ 读锁与读锁可以共享
⚫ 读锁与写锁不可以共享(排他)
⚫ 写锁与写锁不可以共享(排他
5 ReentrantLock 的实现原理
我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串 行执行,从而达到线程安全性的目的。在 synchronized 中,我们分析了偏向锁、 轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销, 同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。 那么在ReentrantLock中,也一定会存在这样的需要去解决的问题。就是在多线程 竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?
AQS 是什么
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它 是一个同步工具也是Lock用来实现线程同步的核心组件。如果你搞懂了AQS,那 么J.U.C中绝大部分的工具都能轻松掌握。
AQS 的两种功能
从使用层面来说,AQS的功能分为两种:独占和共享
独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是 以独占方式实现的互斥锁
共 享 锁 , 允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源 , 比 如 ReentrantReadWriteLock
AQS 的内部实现
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任 意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线 程争抢锁失败后会封装成Node加入到ASQ队列中去;当获取锁的线程释放锁以 后,会从队列中唤醒一个阻塞的节点(线程)。
Node 的组成
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;//排它模式,默认
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
/**
* Status field, taking on only the values:
* SIGNAL: The successor of this node is (or will soon be)
* blocked (via park), so the current node must
* unpark its successor when it releases or
* cancels. To avoid races, acquire methods must
* first indicate they need a signal,
* then retry the atomic acquire, and then,
* on failure, block.
* CANCELLED: This node is cancelled due to timeout or interrupt.
* Nodes never leave this state. In particular,
* a thread with cancelled node never again blocks.
* CONDITION: This node is currently on a condition queue.
* It will not be used as a sync queue node
* until transferred, at which time the status
* will be set to 0. (Use of this value here has
* nothing to do with the other uses of the
* field, but simplifies mechanics.)
* PROPAGATE: A releaseShared should be propagated to other
* nodes. This is set (for head node only) in
* doReleaseShared to ensure propagation
* continues, even if other operations have
* since intervened.
* 0: None of the above
*
* The values are arranged numerically to simplify use.
* Non-negative values mean that a node doesn't need to
* signal. So, most code doesn't need to check for particular
* values, just for sign.
*
* The field is initialized to 0 for normal sync nodes, and
* CONDITION for condition nodes. It is modified using CAS
* (or when possible, unconditional volatile writes).
*/
volatile int waitStatus;
volatile Node prev; //前驱结点
volatile Node next; //后继结点
volatile Thread thread; //当前线程
Node nextWaiter;//存储在condition队列中的后继节点
//是否是共享锁
final boolean isShared() {
return nextWaiter == SHARED;
}
/**
* Returns previous node, or throws NullPointerException if null.
* Use when predecessor cannot be null. The null check could
* be elided, but is present to help the VM.
*
* @return the predecessor of this node
*/
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() { // Used to establish initial head or SHARED marker
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景。
头插法:当向AQS队列插入第一个节点(当前获取锁的线程)时,使用头插法,将head指向自己
尾插法:当向AQS队列插入未抢占锁成功的线程节点时,使用尾查发,从队列尾部插入。(优点:防止判空??)
waitStatus除了默认值外还有三个值,分别代表不同的含义
-
CANCELLED = 1 : 表示当前节点已经不能在获取锁了,当前节点由于超时或者中断而进入该状态,进入该状态的节点状态不会再发生变化,同时后续还会从队列中移除。
-
SIGNAL = -1 : 表示当前节点需要去唤醒后继节点。后继节点入队时,会将当前节点的状态更新为SIGNAL
-
CONDITION = -2 : 当前节点处于条件队列中,当其他线程调用了condition.signal()后,节点会从条件队列转义到同步队列中
-
PROPAGATE = -3 :当共享锁释放的时候,这个状态会被传递到其他后继节点
会涉及到两个变化
- 新的线程封装成Node节点追加到同步队列中(尾查法),设置prev节点以及修改当前节点的前置节点的next节点指向自己
- 通过CAS将tail重新指向新的尾部节点 (同一时刻可能会有多个Node节点插入到等待队列中)
- 设置闹钟waitStatus 。将前一个节点的waitStatus改为Node.SIGNAL=-1 (表示当前节点准备好随时被唤醒,好处是当前一个节点执行完毕后不需要取next去检查状态)
head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点, 如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下
这个过程也是涉及到两个变化
- 修改head节点指向下一个获得锁的节点
- 新的获得锁的节点,将prev的指针指向null
设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成 的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并且断开原head节点的next引用即可
6 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);
}
//公平锁
final void lock() {
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。
参考 update table set age=1 where age=0;
这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作, 以及涉及到state这个属性的意义。
state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入 锁的实现来说,表示一个同步状态。它有两个含义的表示 1 当state=0时,表示无锁状态
2 当 state>0 时,表示已经有线程获得了锁,也就是 state=1,但是因ReentrantLock允许重入,所以同一个线程多次获得同步锁的时候,state会递增, 比如重入5次,那么state=5。
而在释放锁的时候,同样需要释放5次直到state=0 其他线程才有资格获得锁 。
Unsafe 类
Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包 括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、 Hadoop、Kafka等; Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、 线程的挂起和恢复、CAS、线程同步、内存屏障 而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象, 第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第 四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值var4相等,则 更新为新的期望值 var5,如果更新成功,则返回true,否则返回false;
stateOffset
一个Java对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存 里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节 偏移。用于在后面的compareAndSwapInt中,去根据偏移量找到对象在内存中的 具体位置 所以stateOffset表示state这个字段在AQS类的内存中相对于该类首地址的偏移量。
compareAndSwapInt
在unsafe.cpp文件中,可以找到compareAndSwarpInt的实现
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject
unsafe, jobject obj, jlong offset, jint e, jint x)) UnsafeWrapper("Unsafe_CompareAndSwapInt"); oop p = JNIHandles::resolve(obj); //将Java对象解析成JVM的oop(普通 对象指针), jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); //根据对象p 和地址偏移量找到地址 return (jint)(Atomic::cmpxchg(x, addr, e)) == e; //基于cas比较并替换, x表 示需要更新的值,addr表示state在内存中的地址,e表示预期值 UNSAFE_END
ReentrantLock 和 Synchronized 区别
1 一个是关键字 一个是J.U.C
2 释放锁方面:
- Synchronized 1)同步代码块执行完毕 2)发生异常
- ReentrantLock lock.unlock()
3 灵活性 ReentrantLock 更加灵活
4 死锁的方案 使用 tryLock 防止资源占用 发生死锁
5 公平性和非公平性
- 在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。
- 而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。
使用condition实现线程通信
使用 synchronized 时 基于 wait()/notify() 实现线程通信
使用 Lock 时 基于 condition.await() / condition.signal() 实现线程通信
condition
public static void main(String[] args) {
Lock lock=new ReentrantLock();
Condition condition=lock.newCondition();
new Thread(()->{
try {
lock.lock();//竞争锁
System.out.println(" begin -》conditionWait");
condition.await();//阻塞
System.out.println(" end -》conditionWait");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();//释放锁
}
}).start();
new Thread(()->{
try{
lock.lock();//竞争锁
System.out.println("begin ->conditionNotify");
condition.signal();//唤醒阻塞状态的线程
System.out.println("end ->conditionNotify");
} finally {
lock.unlock();//释放锁
}
}).start();
}
运行结果
begin -》conditionWait
begin ->conditionNotify
end ->conditionNotify
end -》conditionWait
为什么AQS是双向队列?为什么condition 是单向队列?
假如你的队列是单向的如:Head -> N1 -> N2 -> Tail。出队的时候你要获取N1很简单,Head.next就行了,入队你就麻烦了,你要遍历整个链表到N2,然后N2.next = N3;N3.next = Tail。入队的复杂度就是O(n),而且Tail也失去他的意义。相反双向链表出队和入队都是O(1)时间复杂度。说白了空间换时间。