java基础复习(八):聊聊synchronized和Lock/AQS

文章目录

  • synchronized
    • synchronized包的是什么?
      • JVM层面
      • monitor
      • 从源码看synchronized
      • 从操作系统看synchronized
    • synchronized的优化
    • 锁升级
      • 偏向锁
      • 轻量级锁
      • 重量级锁
  • API细节
    • 为什么wait/notify需要被同步块包裹
    • sleep与wait
    • yield与join
    • interrupt
    • interrupt总结
    • park/unpark
    • suspend、resume、stop
  • 理解Lock接口
    • synchronized与Lock(reentrantLock)对比
  • 理解AQS框架
  • 简述AQS原理
    • await/signal
    • 公平与非公平
  • 同步组件原理简述
    • semaphore
    • countdownLatch
    • cyclicBarrier

synchronized

synchronized是java实现线程同步的一个关键字,同步就是步调一致,synchronized修饰代码块或者函数,那么这个区域就可以看作一个同步块,不存在某一时刻多个线程同时执行同步块的代码。A线程执行完同步块。
谈到多线程,那就离不开共享变量,如果synchronized包裹的同步块中操作的净是些局部变量,那synchronized同步了个寂寞。什么时候需要同步,那肯定是多个线程并发访问同一个共享变量时才需要同步,A线程在同步块修改完这个共享变量时,B再进入这个同步块,它能立即发现共享变量的最新值,而且它修改共享变量时,不存在其他线程来捣乱的情况。

synchronized包裹的同步块是同步的,具体点:
【1】线程操作同步块代码时,是原子的(即使OS层面存在线程切换,但是java层面我们将线程访问共享变量的整套同步代码的操作看作是原子的)
【2】同步块具有可见性,线程写共享同步块内的共享变量,会使得其他线程保存该共享变量的对应缓存行失效,读共享变量则会重新从主存中去读取。
【3】synchronized块内的代码不会被重排序到synchronized块外。(synchronized同步块可以看作单线程,遵循as-if-serial,会进行重排序优化)

synchronized包的是什么?

学过操作系统都知道,进程/线程同步有很多方式,例如信号量、互斥量,其中还有一种方式就是管程。管程就像一个黑盒子,系统提供给我们使用,他能保证同一时刻只有一个进程/线程可以执行管程包裹的代码,管程为我们隐藏了实现的数据结构等细节,我们只需要关注暴露出的接口。
java程序运行在JVM之上,而JVM本质上就是对计算机的虚拟,那么java系统是否为我们也提供了管程?synchronized就是java实现的管程。

JVM层面

synchronized为用户屏蔽了实现细节,其中进入synchronized在JVM底层对应monitorEnter指令,而出synchronized对应monitorExit指令。monitor翻译过来就是管程的意思。调用synchronized方法时,编译源码后,字节码文件的方法表标识字段会出现ACC_synchronized,底层仍然会调用上面的两个指令。
同时,编译器会在以上指令附近插入内存屏障,告诉操作系统和CPU硬件,在执行该指令时禁止某些优化,来保证相应的可见性和有序性特性。

直接看以上两个指令,就感觉底层肯定有一个叫monitor的数据结构管理着同步状态。

monitor

synchronized包裹的内容可以是字符串、class对象、this(synchronized实例方法包裹的是this,而synchronized类方法包裹的是class对象)等。不管它包裹的什么,那一定是一个对象。

对象锁一般说的是synchronized(this),我创建一个resource对象,然后一堆线程争抢修改共享资源i将会被同步。而如果我再创建一个resource对象则不受影响。那是肯定的啊,this指的就是当前待被创建的实例,肯定只有操作当前实例才会出现“抢锁”啊。

class Resourse{
    int i =0;
    synchronized void f(){
        i++;
    }
}

类锁一般说的是synchronized(xxx.class)或static synchronized,那么不管通过哪个实例去操作资源类,都会被同步。因为不管class对象还是类方法都是属于类的,每个JVM实例只存在一个的,大家抢的都是这一个,和从哪里访问没有关系。

其实,如果直到了“锁”的原理,就没必要如此分析。

首先记住:synchronized关联的是monitor结构,而monitor和Object对象绑定,因此,不严谨的说,所有object对象都能作为“锁”

每个java对象在内存布局中由三部分组成:对象头实例数据填充数据/对齐填充。其中对象头又可以分为两部分:标记字段 mark word类型指针
mark word的结构不是固定的,是动态变化的,根据结果不同可以分为无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
如果一个对象处于重量级锁状态,那么mark word将具有一个指向重量级锁的指针。

重量级锁的创建是延迟的,而且锁升级的出现,主要原因也是为了避免重量级锁的创建。

总结:假设不存在锁升级,一旦线程初次进入synchronized块,将伴随锁(monitor)的创建,并且线程将试图获取这个锁(实现上一般是CAS将owner字段修改为某个线程id)。(重量级锁的叫法大致在,是引入锁升级之后,这里不做区分)
注意:锁本质上只是一个变量,上锁、抢锁实际含义是CAS争抢“置位操作”,用户能直接看见的是synchronize包裹着对象,其实底层线程争抢对object关联的monitor进行置位操作

从源码看synchronized

有兴趣的,可以看一看JVM对应的C++源码,这里我只进行一些个人总结。
synchronized是java对管程的一种实现,使用了某种管程模型,这里指出是为了防止固化思维。
monitor在底层,对应C++定义的objectMonitor。
每个线程都会被抽象为一个对象(类似java的thread,C++也类似,下面指的线程就是一个被抽象出的对象而不是操作系统层面的线程),每个java对象关联的monitor也是一个对象,不过是C++对象。
【1】count 记录重入次数(可重入锁的最大特点就是可以防止多次调用而导致死锁,非可重入锁通常是使用布尔值01进行标记锁的状态,而可重入锁使用一个计数器变量)
【2】owner指向拥有该对象的线程
【3】waitSet 等待队列(wait()调用后,线程被移入该队列,其实就是插入链表队尾,对应java线程的wait状态)
【4】entryList 同步队列(进入synchronized后并且没有获取到锁,则会进入该队列,对应java线程的block状态)

一个线程进入synchronized后便进行一次CAS(CAS(owner,null,cur)试图让自己称为owner),没错,这里强调的就是一次。如果第一次CAS失败则说明抢占失败,通常会进行自适应自旋(重试),如果仍然失败则进入entryList同步队列,并且调用park()阻塞当前线程,底层对应系统调用将当前线程对象映射到的操作系统线程挂起,并让出CPU,这一步通常代价比较大,因为涉及系统调用和线程切换。如果成功将owner修改为自己,则开始执行同步代码,并且将count加一。执行完毕将count减一,复位owner,并且唤起entryList阻塞的线程(实现上通常唤醒队头线程,不过如果没抢到还会进入entryList队尾,通常流动性很大,不会出现饥饿)。
而如果owner线程调用wait,则进入waitSet并阻塞(同样对应park调用),同时让出CPU。只有其他线程调用notify它才会被唤醒,而且唤醒后进入entryList,当owner被复位后,同entryList其他线程进行竞争,当称为owner将从原执行位置继续向下执行。

注意:synchronized阻塞指的通常是synchronized抢占锁失败的行为,即不管互斥锁还是自旋锁指的都是失败后的处理策略

从操作系统看synchronized

monitor的阻塞部分底层依赖操作系统的互斥量(mutex)实现,而上锁部分则依赖CPU的CAS指令。(LockSupport/unsafe提供的park()和atomic/unsafe提供的CAS底层其实也是这一套东西,只不过拿到明面上来了)

而synchronized的可见性和有序性都是CAS保证的(lock cmpxchg),volatile的文章说的比较清楚,这里不展开了。而原子性是由锁保证的(操作同步代码之前,需要先过monitor这一关,你不是owner就别想过去)

synchronized的优化

JDK6之后对synchronized做了一些类优化:
【1】锁升级机制
【2】锁消除。一些框架采用保守策略,将程序基于线程安全实现,锁消除是一种编译器优化,通过逃逸分析消除部分无必要的同步代码。
【3】锁粗化。在编译期间将相邻的同步代码块合并成一个大的同步代码块,减少反复申请、释放造成的开销。(即使每次都可以获得锁,那么频繁的操作底层同步队列也将造成不必要的消耗)
【4】自适应自旋锁,synchronizedCAS占用owner失败后,会进行自旋尝试,这个时间不是固定的,而是前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的

自旋锁的开启:
JDK1.6中-XX:+UseSpinning开启;
-XX:PreBlockSpin=10 为自旋次数;
JDK1.7后,去掉此参数,由jvm控制;

同时,用户也可以具有一些优化意识,如:
锁分离。最常见的就是读写分离。
减少不必要的同步代码、减少同步代码大小,减少锁的粒度(例如jdk1.8concurrentHashMap基于synchronized实现分段加锁,将粒度压缩都了每一个桶)、尽量让同步代码短小精悍,减少锁的持有时间。

锁升级

锁的升级是单向的(也不一定,和具体JVM实现有关),因为达到锁升级的条件,那么对应的场景一定是存在竞争,这时候不适合低级锁进行控制。
锁的状态取决于对象头的mark word低两位。

当对象状态为偏向锁时,mark word存储的是偏向的线程ID,当状态为轻量级锁的时候,存储的是指向线程栈中 lock record 的指针,当状态为重量级锁的时候,指向堆中monitor对象的指针

线程在进入同步块之前,JVM会在当前线程的栈帧中创建一个锁记录 lock record,这个结构用于保存对象头mark word初始结构的复制,称为displaced mark word
其中displaced mark word用于保存对象mark word未锁定状态下的结构(用于替换——因为mark word的结构依据锁的状态不同动态变化着,因此必须有一个结构用于保存mark word的原始状态,这个结构就是保存在线程栈帧中的displaced mark word)。

lock record 总是在进入synchronized被创建,但是不同的锁类型对lock record具有不同的处理,偏向锁中lock record是空的,而轻量级锁和重量级锁中保存了lock record的地址

偏向锁

偏向锁——一段同步代码总是被一个线程所访问(不存在另外一个线程),那么该线程会自动获取锁,降低获取锁的代价。(单线程环境下都是偏向锁)
偏向锁在一个线程第一次访问的时候将该线程的id记录下来,下次判断如果还是该线程就不会加锁了。如果有另一个线程也来访问它,说明有可能出现线程并发。此时偏向锁就会升级为轻量级锁。

偏向锁的目的——在某个线程获得锁之后,消除这个线程重入(CAS)的开销,看起来让这个线程得到了偏向。
偏向锁只需要在设置thread ID时进行一次CAS操作,后续发生重入时仅仅进行简单的thread id检查,并且向线程栈帧中添加一个空的lock record表示重入,不需要CAS指令。(偏向锁一旦被某个线程获得,除非出现竞争导致撤销,否则线程不会主动释放锁即thread id只能被设定一次)

如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起(走到安全点后stop the world),JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

偏向锁是对单线程场景下的优化,例如消除第三方框架同步代码带来的性能损失

轻量级锁

线程试图占用轻量级锁时,必须使用CAS指令,这是相对于偏向锁提升的开销。轻量级锁在对象头的mark word体现中,就是一个指向lock record的指针(偏向锁则是thread id)。
线程monitorenter时,栈帧中创建一个锁记录结构,然后将对象的mark word复制过去,然后使用CAS试图修改对象mark word的lock record地址值,成功则代表成功获取锁,失败则要么存在重入,或者存在竞争并通知JVM执行锁升级
注意:CAS失败的失败策略就是锁升级,不会自旋,CAS获取重锁失败后才会短暂自旋

轻量级锁适用于线程交替执行同步块的情况,如果存在同一时间访问同一锁即冲突访问的情况,就会导致轻量级锁膨胀为重量级锁。在线程总是能交替执行的场景(并发量小、同步代码执行快速),可以防止monitor对象的创建

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能

重量级锁

重量级锁之所以重是因为底层依赖OS的mutex互斥量实现,而依赖堆中的monitor对象(Hotspot对应objectMonitor实现)。
当然了,如果单线程下,或者不存在“竞争明显”的情况下,没有线程会被挂起,也不会出现进程切换,但是仍然需要为使用的锁对象创建绑定的monitor并且频繁CAS设置owner。用户态与内核态的切换主要是由于park()底层涉及系统调用导致的,如果CPU上下文切换的时间接近同步代码的执行时间,那么就显得效率很低下。

如果显示调用了hashCode()、notify、wait方法则会导致对象直接升级为重量级锁。

每个java对象都可以与一个监视器monitor关联,并不是一个java对象就是一个monitor对象,而是每个java对象都可以存在一个指向monitor对象的指针
Monitor并不是随着对象的创建而创建的,而是通过synchronized告诉JVM,需要为某个java对象关联一个monitor对象。每个线程都存在两个objectMonitor对象列表,分别为free和used。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中分配一批monitor到free中。

API细节

其实基于synchronized和JUC的Lock接口实现类使用是相似的,只不过synchronized对应的源码是C++,而Lock实现类对应的是java源码。而且注入sleep、wait等基于native修饰,由对应平台的jvm源码C++实现。

为什么wait/notify需要被同步块包裹

从实现的角度
wait和notify依赖对象绑定的锁,只有获取锁的线程才能执行该方法(需要借助monitor关联的waitSet),否则将会抛出IllegalMonitorStateException异常(没有获取monitor)。

当一个线程调用一个对象/monitor的notify()方法时,调度器会从所有处于该对象/monitor等待队列的线程中取出任意一个线程,将其添加到同步队列中(entry list)。然后在同步队列中的多个线程就会竞争对象的锁,得到锁的线程就可以继续执行。如果等待队列中没有线程,notify()就不会产生作用(相当于对空队列唤醒)。

调用wait(),唤醒的一般是等待队列首线程,如果notifyAll就是依次唤醒队列所有线程。而entryList一般也是从首节点开始唤醒,而竞争主要是entryList之外线程与entryList刚醒来线程之间的竞争

notifyAll()比notify()更加常用, 因为notify()方法只会唤起一个线程(你也不知道等待队列首节点对应哪个线程,因此对用户来说似乎是随机唤醒的), 且无法指定唤醒哪一个线程,所以只有在多个执行相同任务的线程在并发运行时, 我们不关心哪一个线程被唤醒时,才会使用notify()

从设计的角度
因为wait和notify存在竞争关系,wait和notify的调用顺序必须被严格限定。
而且wait通常伴随着条件语句if(A)wait(),而notify则对应A;notify()。同时,为了防止虚假唤醒一般将条件语句换成循环while(A)wait()
wait和notify用于线程通信,肯定是线程A调用if(A)wait()和线程B调用A;notify()。
如果A;notify()和if(A)wait()可以被执行,将会出现死等的问题——A ;if(A) ; notify() wait() 最终的结果是wait()没有notify对它进行唤醒,线程一直阻塞在等待队列中。(死锁、死等导致任务无法被处理、相应内存一直被占用、造成内存泄露浪费线程资源

sleep与wait

sleep来自Thread类,而wait来自Object类。
sleep是线程的行为,而每个Object对象都可以关联一个monitor对象,因此wait/notify被设计属于Object。二者都声明了中断异常(throws InterruptedException),由于java天生就是多线程的,因此任何地方(实例方法、主方法)都可以调用Thread.sleep(xxx),而默认调用方就是主线程。

当然了,这些都是java层面的描述。二者的阻塞JVM底层都依赖park()函数,这会导致线程放弃CPU被挂起。只不过wait搭配wait set使用,因此增加了释放锁的逻辑。而调用sleep时,JVM不关心当前线程是否持有锁,因此调用sleep并不会释放锁。
调用sleep或wait后,java线程处于wait等待状态。

yield与join

yield调用使得当前获得CPU的线程让出CPU资源,以便其他线程有机会抢占(有可能当前线程会再次抢占)。sleep(0)和yield()可以达到相同的效果。

实现上,底层通常会使当前线程放弃CPU资源,同时加入同等优先级队列的末尾。对于和调用线程相同或者更高优先级的线程来说,yield方法给予他们一次运行的机会

而join底层依靠wait/notify实现,使用场景:父线程需要等待子线程的结果即需要等待子线程运行结束。join方法本身也是一个同步方法,而子线程对象本身也是一个Object对象,具有相应的monitor。

        son.join()

主线程中调用son.join()底层相当于调用son.wait(),这里把son线程对象看作一个Object对象、一个对象锁。父线程调用完son.wait()后进入monitor关联的waitSet中。
以下的伪代码中,son有两层含义:子线程对象和monitor

    public static void main(String[] args) {
        synchronized (son){
            while(son.isAlive()){
                son.wait(0);
            }
        }
    }

当son线程执行完毕,会唤醒父线程,同时isAlive()调用结果为false,父线程(主线程)退出等待状态。(notify的调用位于JVM源码中,join的java源码中只能找到wait的调用)

interrupt

jdk主要提供了三个中断相关方法,这里的中断指的是对java线程阻塞打断,如果一个线程正在正常执行,那么不会做出任何反映。

【1】interrupt。在一个线程中调用另一个线程的interrupt()方法,即会向那个线程发出信号——线程中断状态已被设置(set为true)。至于那个线程何去何从,由具体的代码实现决定
【2】isIntercepted。用来判断当前线程的中断状态(true or false)
【3】interrupted。是个Thread的静态方法,用来恢复中断初始状态(检查中断标志,返回一个布尔值并清除中断状态,第二次调用时,中断状态已经被清除,返回false)——检查当前线程的中断标志并且清除(重置为非中断false)

interrupted底层调用了isIntercepted()方法,同时清除了标志位,实质上是返回currentThread()。isInterrupted()并且重置中断标志,这也是它作为一个静态方法存在的原因。

底层,当一个线程被调用interrupt()方法时,JVM拿到这个线程对象(C++),然后插入内存屏障以保证该线程的中断状态的可见性,修改线程对象的中断状态为true。之后对该线程调用unpark函数将线程唤醒。
【1】如果这个线程阻塞在wait、yield、sleep等可中断的方法,线程被唤醒后将检查自身中断标记,如果为true则会抛出interruptException。
【2】如果线程仅仅是阻塞在synchronized对应的entryList,那么被唤醒后会再次产生获取锁,失败则进行进入阻塞状态,不会响应中断
【3】Lock.lock()方法和synchronized差不多,被唤醒后也会调用unsafe封装的park()继续阻塞。而lockInterruptibly被唤醒后则检查中断标记,并抛出异常。

一般情况下,抛出异常时,会清空/重置thread的interrupt标记

总结:线程中断的底层实现中,实际上是将线程唤醒,但是线程如何响应则取决于此时的调用函数

interrupt总结

每个线程会有一个中断标志位,这个中断标志位是由JVM源码层面去维护的,java层面看不见这个标志位,当某个线程去调用这个interrupt方法的时候,本质上是对某个线程的标志位进行了一个置位操作,然后去唤醒一下这个线程。如果这个线程没有进入wait或者timed_wait的状态(这里的状态指的是java线程层面的),那么其实这个interrupt的调用是没有任何效果的。

这个interrupt调用一般和两种行为进行搭配:【1】我们自己去轮询这个中断状态,然后做出相应的相应。【2】配合声明抛出中断异常的调用去使用,例如sleep、wait、以及JUC下lock的各种实现类支持的“可中断锁”去使用。
对于后者,一般都是将自己通过park()调用阻塞起来,而当我们调用interrupt之后,会额外对该线程执行一个unpark()调用。(park和unpark就是以线程为单位的调用),一个线程被unpark()唤醒之后一般也会做出不同的表现,如果是sleep()、wait()这类的调用,线程会检查一下中断标志,如果true则抛出异常并清除中断标志,而阻塞在synchronized同步队列的线程则是“认为自己被虚假唤醒”,于是继续调用park()进行阻塞状态。

另外,由于所有线程如果拿到某一个线程对象的引用,都可以去调用interrupt方法,因此必须保证线程中断标志位的可见性,且修改这个标志也需要是同步的。其中原子性通过java层面的synchronize去保证,可见性则由内存屏障去保证。

JMM向我们保证:线程A对线程B调用interrupt() happens-before 线程B检测到中断事件发生 。说白了,JMM向我们保证,当一个线程对另一个线程调用interrupt,被中断对该操作一定是可见的。不会因为重排序而发生可见性问题。

park/unpark

LockSupport是Java6(JSR166-JUC)引入的一个类,用来创建锁和其他同步工具类的基本线程阻塞原语。底层是对unsafe类对应的park/unpark方法的封装,java实现阻塞与唤醒功能底层绝大多数都依赖了park函数(park底层调用了哪些系统调用和具体平台、操作系统有关)。
park/unpark更加贴近操作系统层面的阻塞与唤醒线程,不需要获取monitor,以线程为单位进行操作。
每个java线程底层都绑定了一个Parker对象,主要有三个字段:counter、condition和mutex
counter用于记录“许可”。
当调用park时,这个变量置为了0;当调用unpark时,这个变量置为1。(二进制置位)
(unpark提供的许可是一次性的,不能叠加,两个函数的调用使得count在0和1直接切换,底层依赖互斥量mutex 系统调用。当许可为0时,线程被挂起,直到再次获得许可)

park和unpark的灵活之处在于,unpark函数可以先于park调用。比如线程B调用unpark函数,给线程A发了一个“许可”,那么当线程A调用park时,它发现已经有“许可”了,那么它会马上再继续运行。但是park()是不可重入的,如果一个线程连续2次调用LockSupport.park(),那么该线程一定会一直阻塞下去(调用的时候,如果资源为1则 不会阻塞线程,如果资源为0则会阻塞进程)

相对于wait/notify,park与unpark的调用顺序不是固定的,而且是以线程为单位的,每个线程需要关联一个Parker对象。而wait需要关联到一个monitor对象的waitSet。park/unpark可以看作wait/notify实现的基础。

wait、notify、synchronized等底层依赖JVM源码级别的park/unpark实现,而java封装了park/unpark,其中用户可以直接使用lockSupport提供的park和unpark函数,而AQS框架实现阻塞与唤醒底层依赖了unsafe提供的park/unpark(也属于native方法,底层是c++提供的)

suspend、resume、stop

以上三个方法都是被淘汰的方法。其中suspend和resume搭配使用,suspend调用后线程不会释放CPU与锁,占用资源睡眠(运行挂起状态、相当于死循环),容易出现死锁,而且即使被挂起仍然处于runnable状态。
stop终结线程时,不保证锁的释放,如果一个持有锁的线程被终结,那么很容易出现死锁。

理解Lock接口

Lock是JUC包下提供的接口,定义了一次锁类型应该具有的行为。
Lock接口的意义就是把锁这个东西抽象为了一个对象拿到台面上来了,而不是像synchronized那样将锁这个东西透明化了。
提供Lock接口,使得一提到锁对象不再只是C++的objectMonitor对象,而也可以是Lock对应的reentrantLock、reentrantReadWriteLock等对象了。
Lock接口提供了与synchronized相似的行为,同时提供了一些额外的特性:
【1】非阻塞获取锁tryLock
【2】可响应中断的上锁方式lockInterruptibly
【3】超时获取锁,在指定的时间内没有获取锁将返回一个布尔值。

另一方面,将Lock从底层抽象出来,也可以使得用户更好的监控锁的行为,如当前的owner是谁?锁是否被获取等。而且可以接着扩展用户子接口,来使得锁可以扩展出更多的行为,使得上锁操作更加灵活可控

synchronized与Lock(reentrantLock)对比

Lock毕竟是一个接口,讨论还是需要具体到某一个实现类上的。以最常用的reentrantLock为例。
上面已经提到过Lock接口本身提供的synchronized不具备的特性:支持超时、非阻塞、响应中断、更好的扩展性
synchronized和reentrantLock都是可重入的,实际上AQS大多数锁都是可重入的,这可以在一定程度上避免死锁的发生
synchronized默认就是非公平的,而Lock只是定义了行为,实现类可以基于非公平和公平进行实现,这也反映基于Lock实现锁更加具有扩展性。

synchronized实现线程通信时搭配wait以及monitor的waitSet,reentrantLock搭配condition对象和await方法。一个synchronized对应一个monitor,因此多个线程调用wait()后将会等待在同一个waitSet。而基于高层实现的reentrantLock可以创建多个condition对象,每个condition对应一个等待队列,因此不同的线程根据不同的等待条件,可以等待在不同的队列,可以使得线程唤醒更加精确。

实际上synchronized的优点也不少:
【1】synchronized使得用户不需要关心上锁、解锁的逻辑,甚至不需要关心锁对象的存在,而我如果想使用reentrantLock,那么我必须显示创建一个对象,并且显示的lock和unlock。而且必须写在try/finally中,因为synchronized隐式帮我们释放锁,即使出现了异常,而reentrantLock使用的过程中出现异常,并且没有处理锁对象的释放,那么可能出现死锁。
【2】以concurrentHashMap 1.8为例,万物皆为monitor,因此可以把数组元素本身看作一个锁,而不需要向concurrentHashMap 1.7那样显示创建锁对象,并且锁的粒度更小,并发度更大。

更深一层,synchronized和reentrantLock实现了相同的特性:可见性、原子性、有序性。
其中reentrantLock实现这些特性极大依赖于底层的AQS框架(AQS框架使得reentrantLock更加关注于如何实现可重入锁的逻辑而不是同步、阻塞等工作)
reentrantLock实现可见性和原子性,基于读写volatile变量和CAS指令,同时通过CAS修改锁变量保证原子性。

实际上,抛开一些细节,reentrantLock可以看作对synchronized基于java代码的再次实现,一些实现逻辑十分相似,底层都离不开CAS加锁以及直接或间接地插入内存屏障。但是reentrantLock仅仅是Lock/AQS的冰山一角而已。synchronized中的可重入锁是透明的,它只是实现管程synchronized的一个组件,而reentrantLock则是被单独提取,提供给用户,出来以进行复用和扩展。

理解AQS框架

AQS abstract queued synchronized 抽象队列同步器,是用于实现锁以及其他同步组件的基础框架,内部实现了线程管理、同步状态管理与队列管理这些“无关性”代码。简化了组件开发者的实现工作——不用去关系线程排队、节点封装等底层细节,只需要去使用或重写框架指定的方法即可。
AQS的设计基于模板方法设计模式

队列同步器面向组件开发者,而组件则面向使用者/一般的程序员。同步器与组件分别为开发者和使用者屏蔽了不必要了解的细节。

如果你想要定义一个自定义组件,仅需要:定义一个实现AQS的静态内部类,组合一个该类型的字段SYN,实现LOCK接口,并且全部委托给这个成员SYN实现即可。唯一需要做的就是:重写AQS中提供的钩子方法(如tryRelease、tryAcquire),同时使用AQS框架已经实现好的方法去实现对应功能(如setExclusiveOwnerThread、getState等)

借助AQS实现互斥量

class Mutex implements Lock {
    private Syn syn= new Syn();

    @Override
    public void lock() {
        syn.acquire(-1);//参数不被tryAcquire使用
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        syn.acquireInterruptibly(-1);
    }

    @Override
    public boolean tryLock() {
        return syn.tryAcquire(-1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return syn.tryAcquireNanos(-1,unit.toNanos(time));
    }

    @Override
    public void unlock() {
        syn.release(-1);
    }

    @Override
    public Condition newCondition() {
        return newCondition();
    }

    static class Syn extends AbstractQueuedSynchronizer {
        ConditionObject newCondition(){
            return new ConditionObject();
        }

        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0,1)){
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            if(getState()==1){
                if(getExclusiveOwnerThread()==Thread.currentThread()){
                    setExclusiveOwnerThread(null);
                    setState(0);
                    return true;
                }
            }
            throw new IllegalMonitorStateException();
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState()==1;
        }
    }
}

【1】创建一个内部类实现AQS(如果想要具有公平和非公平实现,可以另外创建两个内部类,将差异方法空出了,然后让两个内部类再次继承Syn)
【2】实现Lock接口,并且委托Syn对象去提供实现
【3】Syn实现了AQS,就是一个AQS对象,因此可以直接调用AQS框架已经提供出来的方法

简述AQS原理

AQS队列同步器,它主要管理了两个队列/链表:同步队列和等待队列。并且维护了一个同步状态state。这个state是volatile修饰
通过读volatile可以实现加锁的内存语义,而通过写volatile实现解锁的内存语义。
volatile的写与释放锁具有相同的内存语义,而volatile的读与获取锁具有相同的内存语义。
根据happens-before规则,对一个volatile域的写,happens-before于任意后续对这个volatile的读

AQS维护了一个基于双向链表的同步队列,当线程未获取到同步状态时,则该线程会被封装成一个节点,CAS插入队尾,同时调用park()陷入阻塞。
队列的首节点最开始是一个哨兵节点(延迟创建),两个队列外的线程同时去获取state,成功获取state的成为owner,而失败的封装成节点插入队列尾部并阻塞。

一般情况下,同步队列中头结点表示的是获取到同步状态的线程节点,当头结点代表的线程释放了state(state=0),此时线程在释放state后还需要唤醒后继节点队首元素释放节点后,只有后继节点有资格参与和外界的竞争)去获取state。当有线程获取到state时,需要将自己代表的节点更新为头结点。

当前线程成功获取state,那么可能有队列外的线程获取失败,便会被封装入节点进入线程。由于队列中的节点都是延迟创建的,因此如果总是能避免竞争(交替获取)便不会创建任何节点入队。
注意:state有可能是外界线程释放的,也可能是队首节点释放的。最终都会唤醒头结点的后继节点,当一个队内节点对应的线程抢占state成功则将自己置为队头元素,相当于变成了新的哨兵,同时将节点指向线程对象的指针置空(相当于线程出队),而一旦state被释放则哨兵(头结点)的后继元素将被唤醒。

这里以aquire为例

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

一开始AQS实例的head和tail都是空,在addWaiter时需要初始化,head和tail会共同指向同一个空节点,这个空节点的waitStatus默认值就是0,可以看作哨兵节点。
其中addWaiter就是一个底层数据结构入队的过程,返回当前已经插入尾部的节点Node的引用,然后acquireQueud使用这个node去执行抢占state和阻塞的逻辑。

当进入acquireQueud时,node的pre就是node空节点,因此可以直接tryAcquire尝试占有state,如果失败说明外部存在竞争。这时前面的head节点的waitStatus会被调整为signal,然后当前节点阻塞。

compareAndSetWaitStatus(pred, ws, Node.SIGNAL);

一旦释放锁(不管是外部还是头部),都会唤醒头部的后继节点,被唤醒后的后继节点如果成功占用锁,那么将会变成新的头结点,并且释放当前头结点

if (h != null && h.waitStatus != 0) //waitStatus=0不会响应,signal则会响应
    unparkSuccessor(h);

另一方面,如果head为-1(signal)那么后面一定有节点,如果head=0(默认值/初始状态),那么后面的节点一定在设置head=-1的路上,并且没有被阻塞的节点,因此不需要额外执行唤醒。
这保证了,如果waitStatus<0,则后继一定存在需要被唤醒的节点

int ws = node.waitStatus; //头结点释放后会初始化,等同于哨兵——空节点
if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);

如果是外部释放锁,当前头结点即使是空节点,它的状态也是-1,因此具备唤醒后继节点的资格。如果是头部节点释放锁,同理。如果头部或者外部释放锁,而被外部线程拿到,则头部的thread引用已经释放,本质上就是一个哨兵节点(同初始状态的空节点)
(是否是空节点,主要区别是thread引用是否有值)

考虑一种情况,如果同步队列没有等待节点,即线程总是能够交替获得state,因此每当state释放后,同步队列就没有执行唤醒head后继节点的必要

shouldParkAfterFailedAcquire(p, node) &&
    parkAndCheckInterrupt()

如果有其他节点入队,则头部waitStatus=0的节点会被再次设置为-1,保证头结点后面的节点不会“死等”下去。前一个方法只有返回true时才会触发park(),即只有当head(前驱节点)是signal,才能放心进入阻塞状态,0或者1(cancel)都不可以,因为这会导致节点阻塞后不被唤醒。

对于共享模式,与独占模式主要的不同:自己拿到资源后,如果还有剩余量,那么会接着唤醒后继节点(后继接着唤醒后继…以此类推)。而且基于重入的考虑,独占模式下,释放完所有的资源(state=0)时才会唤醒其他线程,而共享模式下,拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点

await/signal

ConditionObject是AQS的内部类,每个conditionObject对象都是一个等待队列,只有同步队列队首元素才可以执行await()方法——封装成一个新的节点添加到condition等待队列的队尾,同时通过LockSupport.park()进行阻塞,并且释放state,唤醒同步队列中的后继节点。

注意:同步队列的首节点并不会直接加入等待队列,而是把当前线程构封装成一个新的节点并将其加入等待队列中

而当另外一个持有state的线程调用condition的signal方法,会将唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将等待队列中的节点移动到同步队列的尾部,直到获取到state才会继续恢复执行。

wait和await的实现很相似,都是将线程节点对象在等待队列与同步队列之间移动,并且提供了一些其他特性:awaitNanos超时等待、awaitUninterruptibly()对中断不敏感(线程中断调用后不抛出异常,而是设置中断标志位true)

总结:Condition等待通知的本质就是等待队列 和 同步队列的交互的过程,跟object的wait()/notify()机制一样。Condition是基于同步锁state实现的,而objec是基于monitor实现的

公平与非公平

reentrantLock是Lock接口的实现类,也是基于AQS框架实现的同步组件,可以看作是java代码层面对synchronized的高层次实现。内部有三个内部类,一个是继承了AQS的同步抽象父类syn,另外两个分别基于syn进行了公平与非公平实现。

synchronized默认就是非公平的,可以提供代码执行吞吐量和并发度。
【1】lock上锁方法中,调用acquire获取锁之前,会先进行一次CAS尝试占用同步状态
【2】重新tryAcquire方法中,如果发现state空闲则会进行一次CAS尝试占用同步状态。(tryAcquire在模板方法至少被调用了两次)
以上两次抢占全部失败之后,才会走AQS模板方法的剩余流程。(创建节点、短暂自旋、阻塞)

而公平实现中,仅当队列中没有等待更久的节点时,才会尝试CAS占用(也就是说,只要队列中有其他节点正在排队,则当前线程就必须往后排队,不能插队)
公平锁对应的同步队列,节点获取同步状态是有严格的顺序要求的,获取公平锁的线程几乎总是需要创建节点和阻塞,导致线程切换频繁、吞吐量下降、并发度下降。

同步组件原理简述

同步组件的实现,本质上都是依赖AQS框架,并且实现框架提供了钩子方法

semaphore

semaphore信号量,它的名字表明了它的功能——信号灯,因此它的作用更倾向于通知,不过二元信号量也可以用于实现互斥关系或前驱关系。
Semaphore主要逻辑:获取state和释放state,可以看作一个共享锁组件。

compareAndSetState(available, remaining))

countdownLatch

countDownLatch锁存器,用于同步一组任务,强制他们等待其他任务执行完毕,相当于jdk中的join函数

典型用法:

CountDownLatch countDownLatch = new CountDownLatch(3);
Runnable runnable = () -> {
    countDownLatch.countDown(); //调用三次,await就可以返回了
};
for (int i = 0; i < 4; i++) {
    new Thread(runnable).start();
}
countDownLatch.await();

将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,被插队的任务调用这个锁存器的await,直至锁存器计数结束

其中await就是申请一个permit,countDown就是释放一个permit。创建countDownLatch时,构造函数传入的就是state的值,申请state时(调用await),只有当state值为0时才能成功申请,否则阻塞。而countDown就是将state减一。

总结:创建countDownLatch时,它有若干个state,而调用await的线程将会阻塞直到state的值变成0,而另外一组线程则负责调用countDown将state减少

一旦state变成0,那么这个CountDownLatch就算使用完毕了,因此它是不能够被复用的。

cyclicBarrier

cyclicBarrier可以达到一种效果,N个线程调用cyclicBarrier.await()进入阻塞(相当于被堵在了一个栅栏处),当N个线程全部调用完毕,则“栅栏打开”,线程集中放行。

CyclicBarrier cyclicBarrier = new CyclicBarrier(5,() -> System.out.println("放行"));
Runnable runnable = () -> {
    try {
        cyclicBarrier.await();//阻塞
    } catch (InterruptedException | BrokenBarrierException e) {
        e.printStackTrace();
    }
};
for (int i = 0; i < 5; i++) {
    new Thread(runnable).start();
}

当第N个线程调用await方法,则N个线程集体放行,并且第N个方法将执行回调函数(其实就是执行传入的runnable接口对应的run方法)

cyclicBarrier依赖reentrantLock和condition对象实现,每个cyclicBarrier底层对应一个reentrantLock实例。可以循环使用,每一代绑定一个generation对象。当调用reset时,将会将当前屏障设置为已经破坏状态,并且唤醒所有阻塞的线程,并且创建新的generation对象

int index = --count;
if (index == 0) { // 释放屏障
    boolean ranAction = false;
    try {
        final Runnable command = barrierCommand;
        if (command != null)
            command.run();// 在最后一个线程上执行回调任务的run方法
        ranAction = true;
        nextGeneration();// 相当于自动重置
        return 0;
    } finally {
        if (!ranAction)
            breakBarrier(); //出异常,则将当前屏障设置为已破坏状态
    }
}

调用await底层对应count变量减一,当减少到0则唤醒所有的等待线程并重置。(parties保存总屏障数量,count对应剩余屏障数量)

if (!timed)
    trip.await(); //阻塞于lock的condition同步队列
else if (nanos > 0L)
    nanos = trip.awaitNanos(nanos);

你可能感兴趣的:(java基础,synchronized,juc,java,多线程,并发编程)