JUC之Locks锁面经整理

照例,首先看看JUC中Locks的结构

ocks的结构

看着很短,但是有关锁的内容却很长,需要时间消化


大致我们可以从以下几个方面来讲解

目录1
目录2

锁是JUC中至关重要的内容,日常开发中也用的很多,对于并发而言更是至关重要,因此在面试中被问起的概率非常高,所以对于这一块的内容要着重掌握


一、locks的概念

java提供各类锁的一个框架。


二、locks分类

(一)按锁的性质划分

1、按多个线程是否按顺序获取锁的角度划分

a、公平锁

当锁被某个线程持有时,新发出请求的线程会被放入队列中,在公平锁中,每一次尝试获取锁都会检查CLH队列中是否仍有前驱的元素,如果仍然有那么继续等待。

获取方法:公平锁在获取锁之前会先判断等待队列是否为空或者自己位于队列首部,如果为true则可以继续获取锁,否则就入队等待。

缺点:公平锁为了保证线程排队,需要增加阻塞和唤醒的时间开销

源码分析:

公平锁源码分析

深入看看hasQueuePredecessors(),作用是判断此线程是否应该放入等待队列。首先说明一下这个等待队列是个头结点不存在元素的队列。h != t && ((s = h.next) == null || s.thread != Thread.currentThread());表示此队列不为空并且这个线程不在头结点处。

b、非公平锁

非公平锁和公平锁在获取锁的方法上,流程是一样的;它们的区别主要表现在“尝试获取锁的机制不同”

获取方法:无视等待队列直接尝试获取锁,如果锁是空闲的,即可获取状态,则获取锁

优点:可以减少唤起线程的开销

缺点:处于等待队列中的线程可能会出现一直获取不到锁的现象(饿死)

饥饿原因:

1.高优先级线程占用所有CPU,低优先级进程一直无法获取

2.一个线程每次竞争锁都失败,而新的线程还在一直不断竞争,从而导致这个线程几乎是一直处于等待中

3.某个线程在某个对象的条件队列上等待,而其他线程不断抢入

final void lock() {

    if (compareAndSetState(0, 1))

        setExclusiveOwnerThread(Thread.currentThread());

    else

        acquire(1);

}

可以看到获取锁的时候是直接去获取的,compareAndSetState(0,1)修改锁的状态,修改成功则表示锁已经被释放了,然后把占有锁的线程改为本线程。

PS:ReentrantLock、ReadWriteLock、Synchronized默认都是非公平模式

c.使用场景

公平锁:线程处理时间过长,一部分客户端获取不到服务(违反公平原则,一直被新的线程占用CPU)

非公平锁:当线程吞吐量大的时候


2、按多个线程是否可以持有同一把锁的角度划分

a.独享锁(互斥锁/写锁):

是指该锁一次只能被一个线程锁持有

ReentrantLock和Synchronized都是独占锁。

ReentrantReadWriteLock为读写锁,对于读操作是共享锁,对于写操作是独占锁。这样可以做到读读操作不互斥,但是读写、写读和写写操作都是互斥的。

PS:CopyOnWriteArrayList,对它的操作可以做到写写互斥、其他三个操作不互斥。

b.共享锁(读锁)

是指该锁可被多个线程所持有


3、按一个线程能否重复获取自己的所得角度划分

a.重入锁(递归锁)

同一个线程而言,它可以重复的获取锁,避免同一个线程重复获取锁发生死锁。

外层方法获得锁之后,进入内层方法调用的方法自动获得,并不会阻塞。(同一个线程,可以多次获得同一把可重入锁)

ReentrantLock就是重入锁,如下:

ReentrantLock重入锁举例  

可以看到,在print()方法的同步块中,调用dosomething()方法时又获得了这把锁,这个时候这把锁还没有释放,也就是上一节中的代码中的state并不为0,但由于是可充入锁,所以并不会阻塞,而是将acquires变量+1。注意可重入锁的可重入性只针对本线程。

b.不重入锁

一个线程多次请求同一把锁,会出现死锁

优点:效率更高

缺点:易发生死锁


(二)按锁的设计方案来分类

1、按多个线程竞争同一把锁是否进行阻塞划分

a.自旋锁

当有另外一个线程来竞争锁时,这个线程会在原地等待,而不是把该线程给阻塞,直到那个获得锁的线程释放之后,这个线程马上获得锁

自旋锁的应用就是CAS操作 + 循环

自旋锁

b.自适应自旋锁

自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自选时间及锁的拥有者状态来决定:

1.如果同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;

2.如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞进程,避免浪费处理器资源。

c.优缺点分析

自旋锁是一种非阻塞锁,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁,自旋锁不会使线程状态发生切换执行速度快,但是会不断消耗CPU。


2、按多个线程并发时锁的力度以及锁竞争划分

a.分段锁

在锁发生竞争时使用独享锁保护受限资源,一次只能有一个线程能访问独享锁,但是这样的话效率很低,因此可以采用一种机制来协调独享锁进而提高并发性,这个机制就是分段锁。

案例:ConcurrentHashMap(JDK1.7)支持多达16个并发的写入操作

ConcurrentHashMap(JDK1.7)

如图所示,ConcurrentHashMap默认分成了16个segment,每个segment都对应一个Hash表,且都由独立的锁(ReetrantLock)。所以这样就每个线程访问一个Segment,就可以并行访问了,从而提高了效率,这就是锁分段。

缺点:与单个锁相比,获取多个锁来实现独占访问将更加困难并且开销更大


3、按多个程序写操作干扰本身线程的程度划分

a.乐观锁:认为每次读取数据时,不会进行其他写操作,如CAS

b.悲观锁:认为每次读取数据时,都会有写操作,如synchronized

c.优缺点分析

悲观锁是指当一个线程获取锁时其他线程只能进行阻塞,并且进程挂起和恢复执行过程中也存在着很大的开销

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程并不会被挂起,而是被告知这次竞争失败,并可以再次尝试。

PS:使用乐观锁如果存在大量写操作,需要不断重试浪费CPU资源


4、按多个线程竞争程度划分

a.偏向锁:只有一个申请锁的线程使用锁

b.轻量锁:多个线程交替使用锁,允许短时间的锁竞争

c.重量锁:有实际竞争,且锁竞争的时间长

优缺点分析::

1.如果一个同步方法,没有多线程竞争,并且总是由同一个线程多次获取锁,如果每次还有阻塞线程,那么对CPU时一种资源的浪费,为了解决这类问题,诞生了偏向锁(偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令,因为只有一个线程在竞争,我们只要去判断该偏向锁中的ThreadID是否为当前线程即可);

2.轻量级锁是通过CAS来避免进入开销较大的互斥操作,CAS是无锁的可以避免线程状态切换带来的开销,但是不适合锁竞争时间长(线程计算慢),非常浪费CPU资源

3.重量级锁更适合时间长的情况下使用,可以避免CPU浪费.

PS:使用Synchronized有进行用户态到内核态的切换,这个过程不是一触而就的,而是经历了偏向锁、轻量锁、重量锁三个过程。


三、为什么使用Lock

       为了解决synchronized阻塞带来的性能问题,JDK1.5提供了线程同步工具Lock接口方便实现各类锁,从Lock提供的各类锁的角度来说,对比synchronized这种悲观锁要显得更加丰富和灵活。

(一)Synchronized

synchronized是java中的一个关键字,也就是说是Java语言内置的特性。

Synchronized是由monitor管程的ObjectMonitor来实现请求和等待的:

1.其中ObjectMonitor有两个队列,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象)

            _WaitSet

            _EntryList

2._Owner指向持有持有Monitor对象的线程,当多个线程同时访问同一段同步代码时,首先会进入_EntryList集合

当线程获取到对象的Monitor后进入_Owner区域,并把Monitor中的owner变量设置为当前线程Monitor中的计数器count+1

若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其它线程进入获取monitor(锁)

3.Synchronized在进行重量级锁之前会先历经偏向锁、轻量级锁和自旋锁

不直接切换成重量级的锁的原因是重量级锁需要进行操作系统Mutex Lock来实现,线程之间的切换需要从用户状态到核心态

4.Synchronized缺陷(重量级锁/悲观锁的缺陷)

a.当一个代码块被Synchronized修饰了,如果一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况

    获取锁的线程执行完了该代码块,然后线程释放对锁的占有;

    线程执行发生异常,此时JVM会让线程自动释放锁。

b.若这个锁获取的线程由于要等待IO或其他原因(如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待

c.程序执行效率低下

Synchronized不具备的特性

(二)Lock和Synchronized的区别

1、Synchronized是属于虚拟机层面,Lock是属于api层面,前者是关键字,后者是java实现的类。也就是说前者是在JVM内部实现得,Lock是在语言层面实现的。

2、JVM内部使用的是monitorenter和monitorexit指令实现;Lock基本的是用state变量来判断是否被锁。

3、Synchronized不用手动释放锁,代码执行完了自动释放,而Lock要手动释放。lock()和unlock()方法必须配对使用。

4、Synchronized是非公平锁,Lock默认是非公平锁,但是可以设置成公平锁。

5、Synchronized不能中断,必须抛出异常或者代码执行完。而Lock可以中断,这也是Lock的特点:

(1)可以设置超时方法tryLock(long timeout, TimeUnit unit);

(2)上锁的时候可以上可中断锁:lockInterruptibly(),线程阻塞在锁着的时候可以在其它线程中调用interrupter()方法中断,抛出InterruptedException异常以供处理,如下例:

lock中断  

Synchronized唤醒线程时只能随机唤醒,Lock可以搭配Condition实现精准唤醒某个线程。通过声明多个Condition来实现,唤醒时唤醒阻塞在某个Condition上的线程。


四、Lock API

1、lock() 获得锁,否则一直等待

2、unlock() 释放锁

3、tryLock() 判断锁状态,锁被占用就返回false,否则返回true

4、tryLock(Long time, TimeUnit unie) 比起tryLock()添加一个时间期限判断

5、lockInterruptibly() 此种获取锁的方式,锁被占用的情况下抛出异常,直接终端,可以去做其他处理

6、Condition newCondition() 通知组件:具有阻塞与唤醒功能


五、Condition

Object类中的wait()、notify()、notifyAll()方法实现了线程之间的通信,而Condition类中的await()、signal()、signalAll()方法也实现了相似的功能。通过Condition能够精细的控制多线程的休眠与唤醒, 对于一个锁,我们可以为多个线程间建立不同的Condition。      

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,每个Condition对象都包含着一个队列,该队列是Condition对象实现等待/通知功能的关键

同步队列:AQS的同步排队用了一个隐式的双向队列,同步队列的每个节点是一个AbstractQueuedSynchronizer.Node实例

等待队列:Conditon的等待队列用了一个隐式的单向队列,等待队列的每个节点是一个AbstractQueuedSynchronizer.Node实例

总结:

1:整个过程是节点在同步队列和等待队列中来回移动实现的

2:每当一个线程调用Condition.await()方法,那么该线程会释放锁,构造成一个Node节点加入到等待队列的队尾,自旋判断如果当前节点没有在同步队列上则将当前线程阻塞

3:调用Condition的signal()方法,会将节点移到同步队列中,但不一定在队首

4:如果退出自旋说明当前节点已经在同步队列上。通过acquireQueued将阻塞直到当前节点成为队首,即当前线程获得了锁。然后await()方法就可以退出了,让线程继续执行await()后的代码


六、AbstractQueuedSynchronizer(AQS)

AQS是locks的核心,就好像前端控制器对于springMVC来说一样。

AQS提供一个框架,一个FIFO的等待队列和一个代表状态的int值,子类需要定义这个状态的protected方法,定义什么状态获取到状态以及释放锁状态,该类方法提供所有入队和阻塞机制,AQS框架提供了一套通用的机制来管理同步状态、阻塞/唤醒线程、管理等待队列,是JUC上大多数同步器的基础:

1、模板模式:定义一个操作中算法的骨架,将一些步骤延迟到子类中。

2、模板方法:AQS的一组模板方法,直接调用同步方法组件

模板方法使得子类可以不改变算法的结构即可重定义该算法的某些特定步骤

AQS的模版方法

3、同步组件:程序员需要选择覆盖实现以下方法来时下能同步状态的管理

同步组件

4、同步状态:同步状态的管理配合同步组件使用

同步状态

CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,在CLH同步队列中,一个节点表示一个线程

总结:线程会首先尝试获取锁,失败则将当前线程以及等待状态等信息包成一个Node节点加到CLH队列来管理锁,接着通过死循环尝试获取锁,在拿到锁为止会不断阻塞,等到线程释放锁时,会唤醒队列中的后继线程


七、ReentrantLock

语法:ReentrantLock(boolean fair) 创建一个(公平/非公平)的具有可重入性质的对象

1、非公平(默认)

ReentrantLock 非公平锁  

(1)通过CAS加锁,如果成功则setExclusiveOwnerThread(设置独占锁);如果不成功代表已经持有锁是一个重入的线程

(2)acquire为AbstractQueuedSynchronizer的模块方法,获取线程并调用compareAndSetState进行设置(加锁),加锁失败代表锁已经被占用,则判断当前线程是否是锁的持有者,如果是则代表是一把重入锁,通过setState不断累加锁重入的次数。

释放锁

如果当前线程不是锁的持有者,抛出异常,锁纪录为0代表释放全部,返回true,否则设置同步状态返回false

2、公平锁

ReentrantLock 公平锁

调用hasQueuedPredecessors判断:当前线程不是同步队列有其他线程优先则返回false不能获取锁


八、ReentrantReadWriteLock

ReentrantReadWriteLock  特性

ReentrantReadWriteLock为读写锁,对于读操作是共享锁,对于写操作是独占锁

读写锁是依赖AQS框架实现的共享锁与排它锁,AQS 维护一个state是32位的,内部类Sync采用一套运算规则,实现了高位保存共享锁,低位保存独占锁的一套逻辑

源码大家感兴趣可以自己去看看

实例:

实例

结果运行的效果是:先每隔 一秒打印一次 ti,说明写写互斥(每隔1s打印),写读互斥(打印时不输出读操作的输出),如果把两个循环交换顺序,可以看到,程序先等一秒,然后一次性输出十次数据,接着再一秒一次打印 ti, 说明读读不互斥(一次性全输出),读写互斥(读操作的sleep 1s的过程中,并没有输出写操作的输出)。


九、LockSupport

Object类的wait/notify机制相比,park/unpark有两个优点:1. 以thread为操作对象更符合阻塞线程的直观定义;2. 操作更精准,可以准确地唤醒某一个线程

每个线程都有一个许可(permit),permit只有两个值1和0,默认是0,通过park/unpark设置

补充知识:为什么已经有ReentrantReadWriteLock,jdk1.8还要引入StampedLock?

JDK1.8引入StampedLock,解决如果一直有读操作导致写操作饥饿的问题。

读操作一直都能抢占到CPU时间片,而写操作一直抢不了,可能导致写的饥饿问题,正因为ReentrantReadWriteLock出现了读和写是互斥的情况,这个地方需要优化,因此就出现了StampedLock

StampedLock源码

tryOptimisticRead():当前没有线程持有写锁,则简单的返回一个非 0 的 stamp 版本信息

validate():检测乐观读版本号是否变化

你可能感兴趣的:(JUC之Locks锁面经整理)