进程和线程

操作系统如何调度线程

操作系统会把不同的线程调度到同一个CPU上运行。每个线程运行时又都会使用CPU的寄存器,但每个CPU却只有一组寄存器,所以操作系统在线程B调度到CPU上运行时,需要首先把刚刚正在运行的线程A所使用到的寄存器的值全部保存在内存之中,然后再把保存在内存中的线程B的寄存器的值全部又放回CPU的寄存器,这样线程B就能恢复到之前运行的状态接着运行。

线程调度时操作系统需要保存和恢复的寄存器除了通用寄存器之外,还包括指令指针寄存器rip以及与栈相关的栈顶寄存器rsp栈基址寄存器rbprip寄存器决定了线程下一条需要执行的指令,2个栈寄存器确定了线程执行时需要使用的栈内存。所以恢复CPU寄存器的值就相当于改变了CPU下一条需要执行的指令,同时也切换了函数调用栈。因此从调度器的角度来说,线程至少包含以下3个重要内容:

  • 一组通用寄存器的值
  • 将要执行的下一条指令的地址

操作系统线程模型

操作系统线程模型

用户空间下的线程

当线程在用户空间下实现时,操作系统对线程的存在一无所知,操作系统只能看到进程,而不能看到线程。所有的线程都是在用户空间实现。在操作系统看来,每一个进程只有一个线程。过去的操作系统大部分是这种实现方式,这种方式的好处之一就是即使操作系统不支持线程,也可以通过库函数来支持线程。在这在模型下,程序员需要自己实现线程的数据结构、创建销毁和调度维护。也就相当于需要实现一个自己的线程调度内核,而同时这些线程运行在操作系统的一个进程内,最后操作系统直接对进程进行调度。
图片说明

这样做有一些优点,首先就是确实在操作系统中实现了真实的多线程,其次就是线程的调度只是在用户态,减少了操作系统从内核态到用户态的切换开销。这种模式最致命的缺点也是由于操作系统不知道线程的存在,因此当一个进程中的某一个线程进行系统调用时,比如缺页中断而导致线程阻塞,此时操作系统会阻塞整个进程,即使这个进程中其它线程还在工作。还有一个问题是假如进程中一个线程长时间不释放 CPU ,因为用户空间并没有时钟中断机制,会导致此进程中的其它线程得不到 CPU 而持续等待。

内核空间下的线程

内核线程就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。通俗的讲就是,程序员直接使用操作系统中已经实现的线程,而线程的创建、销毁、调度和维护,都是靠操作系统(准确的说是内核)来实现,程序员只需要使用系统调用,而不需要自己设计线程的调度算法和线程对 CPU 资源的抢占使用。轻量级进程(LWP)是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。内核线程只能由内核管理并像普通进程一样被调度。

图片说明

这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。

用户线程与轻量级进程混合

在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系

图片说明

  • 用户线程的创建、析构、切换等操作很廉价;支持大规模的用户线程并发;
  • 操作系统提供的轻量级进程作为用户线程和内核线程之间的桥梁,提供线程调度功能及处理器映射;
  • 用户线程的系统调用通过轻量级进程完成,降低了整个进程完全被阻塞的风险;
  • 这种用户线程和轻量级进程的数量比不定,即N:M的关系,称为多对多的线程模型

进程和线程的区别

进程:

  • 进程是操作系统结构的基础
  • 程序在一个数据集合上运行的过程
  • 系统进行资源分配调度的独立单位

线程:

  • 轻装进程
  • 进程中独立运行的子任务
  • CPU调度分派的基本单位
  • 基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器一组寄存器)。但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉可能导致整个进程死掉,所以多进程的程序要比多线程的程序健壮。但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

  • 一个程序至少有一个进程,一个进程至少有一个线程
  • 线程的划分尺度小于进程,使得多线程程序的并发性高
  • 进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率
  • 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制
  • 多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配
  • 线程的创建和切换开销比进程小

Java线程的本质

Java线程在JDK1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。JDK1.2及以后,JVM选择了更加稳健且方便使用的操作系统原生的线程模型,通过系统调用,将程序的线程交给了操作系统内核进行调度。现在的Java中线程的本质,其实就是操作系统中的线程,Linux下是基于pthread库实现的轻量级进程,Windows下是原生的系统Win32 API提供系统调用从而实现多线程。

Java线程的生命周期状态转移

图片说明

无论是Timed Waiting ,Waiting还是Blocked,对应的都是操作系统线程的waiting(等待)状态。而Runnable状态,则对应了操作系统中的ready和running状态。

Blocked 是指线程正在等待获得锁;而Waiting是指线程正在等待其他线程发来的通知,收到通知后可能会顺序向后执行,也可能去再次尝试获得某个锁进而被阻塞。

锁池与等待池

Java 平台中,每个对象Object都有一个唯一与之对应的内部锁(Monitor)。Java 虚拟机会为每个对象维护两个“队列”(姑且称之为“队列”,尽管它不一定符合数据结构上队列的“先进先出”原则):一个叫Entry Set(有人翻译为锁池),另外一个叫Wait Set(有人翻译为等待池)。对于任意的对象objectX,objectX的 Entry Set 用于存储等待获取 objectX 对应的内部锁的所有线程。objectX 的Wait Set用于存储执行了 objectX.wait()/wait(long) 的线程。

锁池的本质就是假设线程A已经拥有了某个对象(不是类)的锁,而其它线程B、C想要调用这个对象的某个 synchronized 方法(或者块),由于这些 B、C 线程在进入对象的 synchronized 方法(或者块)之前必须先获得该对象锁的拥有权,而恰巧该对象的锁目前正被线程 A 所拥有,所以这些 B、C 线程就进入了该对象的锁池,这就是锁池。

等待池的本质就是假设线程 A 调用了某个对象的 wait() 方法,线程 A 就会释放该对象的锁(因为 wait() 方法必须在 synchronized 中使用,所以执行 wait() 方法前线程 A 已经持有了该对象的锁),同时线程 A 就进入到了该对象的等待池中。如果此时线程 B 调用了相同对象的 notifyAll() 方法,则处于该对象等待池中的线程不进入该对象的锁池但是将一起争夺锁的拥有权。而如果此时线程 B 调用的是相同对象的 notify() 方法,则仅仅会有一个处于该对象等待池中的线程(随机)参与争夺锁的拥有权。

从 Java 虚拟机性能的角度来说,Java 虚拟机没有必要在 notifyAll 调用之后将 Wait Set 中的线程移入 Entry Set 。首先,从一个队列移动到另外一个队列是有开销的,其次,虽然 notifyAll 调用后 Wait Set 中的多个线程会被唤醒,但是这些被唤醒的线程极端情况下可能没有任何一个能够获得锁(比如被其他活跃线程抢先下手了)或者即便可以获得锁也可能不能继续运行(比如这些等待线程所需的等待条件又再次不成立)。那么这个时候,这些等待线程仍然需要老老实实在wait set中待着。因此,如果 notifyAll 调用之后就将等待线程移出 wait set 会导致浪费(白白地进出队列)。

锁池与等待池参考1
锁池与等待池参考2
锁池与等待池参考3

如何判断线程是否持有Java对象的锁

  • 调用 对象.wait(),如果这个调用抛出异常,这意味着 Java 中的线程并未持有锁,否则线程持有锁
  • 调用静态方法 holdsLock(Object obj),它根据线程是否对传递的对象持有锁来返回 true 或 false 。

Java线程通信的方式

  • wait()/notify()
  • 使用方法 join()
  • 管道输入输出流,使用内存作为传输媒介
  • 生产者-消费者问题模型
  • 使用 condition 控制线程通信
  • 使用阻塞队列 (BlockingQueue) 控制线程通信

常见的进程调度的方式

  • 优先调度算法:先来先服务;短作业优先
  • 高优先权调度算法:抢占式;非抢占式;高响应比优先
  • 基于时间片轮转:时间片轮转;多级反馈队列调度

Java多线程常见API

wait() , notifyAll() 与 notify()

public final void wait() throws InterruptedException,IllegalMonitorStateException

该方法用来将当前线程置入休眠状态,直到接到通知或被中断为止。在调用 wait()之前,线程必须要获得该对象的对象级别锁,即只能在同步方法或同步块中调用 wait()方法。进入 wait()方法后,当前线程释放锁。在从 wait()返回前,线程与其他线程竞争重新获得锁。如果调用 wait()时,没有持有适当的锁,则抛出 IllegalMonitorStateException,它是 RuntimeException 的一个子类,因此,不需要 try-catch 结构。

public final native void notify() throws IllegalMonitorStateException

该方法也要在同步方法或同步块中调用,即在调用前,线程也必须要获得该对象的对象级别锁,的如果调用 notify() 时没有持有适当的锁,也会抛出 IllegalMonitorStateException。该方法用来通知那些可能等待该对象的对象锁的其他线程。如果有多个线程等待,则线程规划器任意挑选出其中一个 wait()状态的线程来发出通知,并使它等待获取该对象的对象锁(notify 后,当前线程不会马上释放该对象锁,wait 所在的线程并不能马上获取该对象锁,要等到程序退出 synchronized 代码块后,当前线程才会释放锁,wait所在的线程也才可以获取该对象锁),但不惊动其他同样在等待被该对象 notify 的线程们。当第一个获得了该对象锁的 wait 线程运行完毕以后,它会释放掉该对象锁,此时如果该对象没有再次使用 notify 语句,则即便该对象已经空闲,其他 wait 状态等待的线程由于没有得到该对象的通知,会继续阻塞在 wait 状态,直到这个对象发出一个 notify 或 notifyAll 。这里需要注意:它们等待的是被 notify 或 notifyAll,而不是锁。这与下面的 notifyAll() 方法执行后的情况不同。

public final native void notifyAll() throws IllegalMonitorStateException

notifyAll 使所有原来在该对象上 wait 的线程统统退出 wait 的状态(即全部被唤醒,不再等待 notify 或 notifyAll,但由于此时还没有获取到该对象锁,因此还不能继续往下执行),变成等待获取该对象上的锁,一旦该对象锁被释放(notifyAll 线程退出调用了 notifyAll 的 synchronized 代码块的时候),他们就会去竞争。如果其中一个线程获得了该对象锁,它就会继续往下执行,在它退出 synchronized 代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。

yield() 与 wait()

  • wait() 让线程由运行状态进入到等待(阻塞)状态,yield() 是让线程由运行状态进入到就绪状态
  • wait() 会让线程释放它所持有对象的同步锁,而 yield() 方法不会释放锁

sleep() 与 wait()

  • wait() 的作用是让当前线程由运行状态进入等待(阻塞)状态的同时,也会释放同步锁。sleep() 的作用是也是让当前线程由运行状态进入到*休眠(阻塞)状态 *
  • wait() 会释放对象的同步锁,而 sleep() 则不会释放同步锁
  • sleep() 方法导致了程序暂停执行指定的时间,让出 cpu 给其他线程,但是监控状态依然保持,当指定的时间到了又会自动恢复运行状态;而 wait() 也会让出CPU,但是必须经过 notify() 或 notifyAll() 后才能参与竞争 CPU
  • sleep() 方法可以在任何地方使用;wait() 方法则只能在同步方法或同步块中使用
  • sleep() 是 Thread 类的方法,wait() 是 Object 类中定义的方法

run() 与 start()

  • run() 方法只是类的一个普通方法而已,如果直接调用 run 方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,要等待 run 方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的
  • 用 start() 方法来启动线程,真正实现了多线程运行,这时无需等待 run 方法体代码执行完毕而直接继续执行下面的代码。通过调用 Thread 类的 start() 方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到 cpu 时间片,就开始执行 run() 方法,这里方法 run()称 为线程体,它包含了要执行的这个线程的内容,run 方法运行结束,此线程随即终止。

join(long)与sleep(long)

join(long) 在内部使用 wait(long) 实现,故 join(long) 具有释放锁的特点。sleep(long) 不释放锁。

为什么wait()和notify()必须在同步块内

多线程编程里有一个臭名昭著的问题 Lost wake-up problem 。假如有两个线程,一个消费者线程,一个生产者线程。生产者线程的任务可以简化成将count加一,而后唤醒消费者;消费者则是将count减一,而后在减到0的时候陷入睡眠:

// 生产者
count+1;
notify();

// 消费者
while(count<=0){
  wait();
}
count--;

当按照如下流程执行时,将会出现消费者醒着但是却无法唤醒的情况。,问题的根源在于,消费者在检查 count 到调用 wait() 之间,count 就可能被改掉了。
图片说明

Java强制我们的 wait()/notify() 调用必须要在一个同步块中,就是不想让我们在不经意间出现这种lost wake up问题。准确的来说,即便是我们自己在实现自己的锁机制的时候,也应该要确保类似于 wait() 和 notify() 这种调用,要在同步块内,防止使用者出现lost wake up问题。

Java如何结束线程

  • 使用标志位终止线程
  • 使用 interrupt() 方法来终止线程
  • 使用 stop 方法强制终止线程(禁止)

和interrupt相关的方法

在Java中有三个和interrupt相关的方法,分别是:

  • interrupt:interrupt 方法是用于中断线程的,调用该方法的线程的状态将被置为"中断"状态。注意:线程中断仅仅是设置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出InterruptedException的方法,比如这里的 sleep ,以及 Object.wait 等方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常后调用 break 来终止线程。
  • interrupted:用于判断线程是否处于中断状态。额外地,静态方法 interrupted 将会在第一次调用后清除中断状态。interrupted 作用于当前线程。
  • isInterrupted:用于判断线程是否处于中断状态。isInterrupted不具有清除状态的功能。isInterrupted 是作用于调用该方法的线程对象所对应的线程(线程对象对应的线程不一定是当前运行的线程,例如我们可以在A线程中去调用B线程对象的isInterrupted方法)

调用interrupt一定会抛出异常么?

不会,下述情况会抛出异常:

  • 如果线程堵塞在object.wait()、Thread.join()和Thread.sleep(),将会抛出InterruptedException,同时清除线程的中断状态
  • 如果线程堵塞在java.nio.channels.InterruptibleChannel的IO上,Channel将会被关闭,线程被置为中断状态,并抛出java.nio.channels.ClosedByInterruptException
  • 如果线程堵塞在java.nio.channels.Selector上,线程被置为中断状态,select方法马上返回,类似调用 wakeup 的效果

多线程是肯定比单线程好?

多线程虽然可以带来更好的并发能力,但是并发编程并不能提高程序运行的速度,还会带来很多衍生问题,例如:内存泄漏、上下文切换、死锁等问题。

什么是下上下文切换

当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换会这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
切换流程:

  • 挂起某进程,将该进程在 CPU 中的状态(上下文)存储于内存中的某处
  • 在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
  • 跳转到程序计数器所指向的位置,以恢复该进程在程素中

切换原因:

  • 在当前执行任务的时间用完后,系统 CPU 正常调度下一个任务
  • 当前执行任务遇到 IO 阻塞,调度器将当前任务挂起,执行下一个任务
  • 多个任务抢占锁资源,当前任务没抢到锁资源,被调度器挂起,继续下一任务
  • 用户代码挂起当前任务,让出 CPU 时间
  • 硬件中断

如何减少上下切换的方法

  • 基于无锁并发编程/CAS算法
  • 使用最少线程
  • 协程
  • 加 CPU
  • 自旋

线程死锁与死锁的避免方式

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。避免方式:

  • 破坏互斥条件:不靠谱
  • 破坏请求与保持条件:一次性申请所有资源
  • 破坏不剥夺条件:申请不到新资源时,主动释放已占有的资源
  • 破坏循环等待条件:按某一顺序申请资源,释放资源则反序释放

线程池

使用线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

在 Java 中有两种线程池的实现方式,分别是通过 Executors 的四种静态方法 FixedThreadPool 、 SingleThreadPool 、 CachedThreadPool 以及 ScheduledThreadPool 实现;或通过 ThreadPoolExecutor 实现。线程池不建议使用 Executors 去创建,而是通过ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。Executors返回的线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
  • CachedThreadPool 和 ScheduledThreadPool:允许创建的线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

线程池设计时核心线程数和最大线程数要考虑什么因素

要想合理的配置线程池的大小,首先得分析任务的特性,可以从以下几个角度分析:

  • 任务的性质:CPU 密集型任务、IO 密集型任务、混合型任务
  • 任务的优先级:高、中、低
  • 任务的执行时间:长、中、短
  • 任务的依赖性:是否依赖其他系统资源,如数据库连接等

对于不同性质的任务来说,CPU 密集型任务应配置尽可能少的线程,如配置 CPU个数+1 的线程数;IO 密集型任务应配置尽可能多的线程,因为 IO 操作不占用 CPU,为了不让 CPU 闲下来,应加大线程数量,如配置 两倍CPU个数+1 ;而对于混合型的任务,如果可以拆分,拆分成 IO 密集型和 CPU 密集型分别处理,前提是两者运行的时间是差不多的,如果处理时间相差很大,则没必要拆分了。常见的估值公式为:

最佳线程数目 = (( 线程等待时间 + 线程CPU时间 ) / 线程CPU时间 ) * CPU数目 

比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32s
线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

线程池的基本组成

  • 线程池管理器
  • 工作线程
  • 任务接口
  • 任务队列

Executors 的若干线程池分别适合什么场景

  • newSingleThreadExecutor:单个线程的线程池,即线程池中每次只有一个线程工作,单线程串行执行任务具有有序性
  • newFixedThreadExecutor(n):固定数量的线程池,没提交一个任务就是一个线程,直到达到线程池的最大数量,然后后面进入等待队列直到前面的任务完成才继续执行
  • newCacheThreadExecutor:可缓存线程池,当线程池大小超过了处理任务所需的线程,那么就会回收部分空闲(一般是60秒无执行)的线程,当有任务来时,又智能的添加新线程来执行
  • newScheduleThreadExecutor:大小无限制的线程池,支持定时和周期性的执行线程

ThreadPoolExecutor基本概念

ThreadPoolExecutor 提供四个构造方法:

public class ThreadPoolExecutor extends AbstractExecutorService {
    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue);

    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory);

    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler);

    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);
}

核心参数:

  • corePoolSize:核心池的大小。在创建了线程池后,线程池默认没有线程,而是等待有任务到来才创建线程去执行任务。除非调用了 prestartAllCoreThreads() 或者 prestartCoreThread() 方法,从这2个方法的名字就可以看出,是预创建线程的意思。即在没有任务到来之前就创建corePoolSize 个线程或者一个线程。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把到达的任务放到缓存队列当中
  • maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数,它表示在线程池中最多能创建多少个线程
  • keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于 corePoolSize 时,keepAliveTime 才会起作用。当线程池中的线程数大于corePoolSize 时,如果一个线程空闲的时间达到 keepAliveTime ,则会干掉这个线程。这一过程直到线程池中的线程数不超过 corePoolSize 时终止。但是如果调用了 allowCoreThreadTimeOut(boolean) 方法,在线程池中的线程数不大于 corePoolSize 时, keepAliveTime 参数也会起作用,直到线程池中的线程数为0
  • unit:参数keepAliveTime的时间单位,有7种取值
  • workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响,一般来说选择 ArrayBlockingQueue 、 LinkedBlockingQueue 或 SynchronousQueue
  • threadFactory:线程工厂,主要用来创建线程
  • handler:表示当拒绝处理任务时的策略,有以下四种取值
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。 
ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。 
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务 

ThreadPoolExecutor类中有几个非常重要的方法:

  • execute():execute()方法实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行
  • submit():submit()方法是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,但是它和execute()方法不同,它能够返回任务执行的结果,去看submit()方法的实现,会发现它实际上还是调用的execute()方法,只不过它利用了Future来获取任务执行结果
  • shutdown():用来关闭线程池
  • shutdownNow():用来关闭线程池

ThreadPoolExecutor进阶

线程池状态如下:

volatile int runState;
static final int RUNNING    = 0;
static final int SHUTDOWN   = 1; // 此时线程池不能够接受新的任务,它会等待所有任务执行完毕;
static final int STOP       = 2; // 此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;
static final int TERMINATED = 3; // 所有工作线程已经销毁

runState 表示当前线程池的状态,它是一个 volatile 变量用来保证线程之间的可见性。

任务缓存队列及排队策略基于 workQueue,workQueue的类型为BlockingQueue<runnable>,通常可以取下面三种类型: </runnable>

  • ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小
  • LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE
  • synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

  • ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常
  • ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
  • ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务

ThreadLocal

ThreadLocal 是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰。在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。ThreadLocalMap 是 ThreadLocal 的内部类,没有实现 Map 接口,用独立的方式实现了 Map 的功能,其内部的 Entry 也独立实现。 ThreadLocalMap 用于存储每一个线程的变量副本, Map 中元素的键为线程对象,而值对应线程的变量副本。在并发环境下,服务器为每个用户开一个线程创建一个ThreadLocal变量来存放用户信息;对于数据库的并发操作,我们可以用一个 ThreadLocal 变量来存放 Connection;在 spring 中也经常出现,如 Bean、事务管理、任务调度、 AOP 等。

ThreadLocal类

Thread 类中有两个变量 threadLocals 和 inheritableThreadLocals ,二者都是 ThreadLocal 内部类 ThreadLocalMap 类型的变量。我们通过查看内部内 ThreadLocalMap 可以发现实际上它类似于一个 HashMap 。在默认情况下,每个线程中的这两个变量都为 null 。只有当线程第一次调用ThreadLocal的set或者get方法的时候才会创建他们。ThreadLocal 类型的本地变量存放在具体的线程空间上,其本身相当于一个装载本地变量的工具壳,通过 set 方法将 value 添加到调用线程的 threadLocals 中,当调用线程调用 get 方法时候能够从它的threadLocals 中取出变量。

图片说明

public void set(T value) {
        // 获取当前线程(调用者线程)
        Thread t = Thread.currentThread();
        // 以当前线程作为key值,去查找对应的线程变量,找到真正存数据的map
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
}
public T get() {
        // 获取当前线程
        Thread t = Thread.currentThread();
        // 获取当前线程的threadLocals变量
        ThreadLocalMap map = getMap(t);
        // 如果threadLocals变量不为null,就可以在map中查找到本地变量的值
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
}

同一个 ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。想要实现带有继承关系的功能,请使用InheritableThreadLocal 类。

ThreadLocalMap 内部实现

ThreadLocalMap 内部实际上使用 Entry 数组存放变量。而这个数组元素继承自 WeakReference 的 key 是指向 ThreadLocal 的弱引用和与之对应的value值(该value值就是通过ThreadLocal的set方法传递过来的值)。ThreadLocalMap 可以被看做***版的 HashMap,在这里桶下标通过 & 求得二分位运算。

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    private Entry[] table;
}

Thread ThreadLocalMap 与 ThreadLocal的关系

图片说明

每一个 Thread 对象里都有一个 ThreadLocalMap 的变量,名称叫 threadLocals ,初始为 null 。访问时如果为 null ,就会创建对象。一个 Thread 实例维护一个 ThreadLocalMap 对象。ThreadLocalMap 是实际存数据的载体,本身提供的查询添加数据的功能。ThreadLocalMap 中通过访问 Entry[i] 找到一个具有键值对形式的变量组,其中 key 是一个 ThreadLocal 类型的弱引用, value 是具体的值。ThreadLocal 可以被理解为线程独享变量的维护车间,提供了维护某个变量的各种方法,为了管理某个线程 A 中的私有变量,必须通过 key 类型为 ThreadLocal 才能找到维护变量的方法。

ThreadLocal中的内存泄露

为什么 ThreadLocalMap 使用弱引用存储 ThreadLocal ?假如使用强引用,当 ThreadLocal 不再使用需要回收时,发现某个线程中 ThreadLocalMap 存在该 ThreadLocal 的强引用,无法回收,造成内存泄漏。因此,使用弱引用可以防止长期存在的线程(通常使用了线程池)导致 ThreadLocal 无法回收造成内存泄漏。

我们注意到 Entry 对象中,虽然 Key 是通过弱引用引入,但是 value 本身通过强引用引入。这就导致,如果不作任何处理,由于 ThreadLocalMap 和线程的生命周期是一致的,当线程资源长期不释放,即使 ThreadLocal 本身由于弱引用机制已经回收,但 value 还是驻留在线程的 ThreadLocalMap 的 Entry 中。即存在 key 为 null ,但 value 却有值的无效的 Entry ,导致内存泄漏发生。

ThreadLocal 内部使用 expungeStaleEntry 函数防止内存泄漏的工作。这个方法的作用是擦除某个下标的 Entry(置为null,可以回收),同时检测整个 Entry[] 表中对 key 为 null 的 Entry 一并擦除,重新调整索引。每次调用 ThreadLocal 的 get 、set 、remove 方法时都会执行。

最佳实践:在 ThreadLocal 使用前后都调用 remove() 清理,同时对异常情况也要在 finally 中清理。

同步

synchronized的内部实现以及优化

当一个线程试图访问同步代码块时,它必须先得到锁,退出或抛出异常的时候必须释放锁。synchronized 的原理是 JVM 基于进入和退出 Monitor 对象来实现同步和代码块同步。代码块同步使用 monitorenter 指令和 monitorexit 指令来实现。monitorenter 指令是插入到同步同步块开始的位置,而 monitorexit 是插入到方法结束处异常处。JVM 要保证每个 monitorenter 指令和 monitorexit 指令匹配。任何对象都有一个 monitor 与之关联,当有一个 monitor 被持有后,它将处于锁定状态。线程执行 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。 synchronized 的优化即 JDK6 以后引入的偏向锁、轻量级锁。Java 中每个对象都可以作为锁,具体表现为如下形式:

  • 对于普通同步方法,锁的目标是当前实例对象
  • 对于静态同步方法,所得目标是类的 Class 对象
  • 对于同步方法块,锁是 synchronized 括号里的对象

Java对象头

synchronized 是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在于 Java 对象头,而 Java 对象头又是什么呢?以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。如果对象是数组类型,则虚拟机使用 3 个字宽存储对象头,如果对象是非数组类型,则用 2 个字宽存储对象头。

图片说明

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外消耗,和执行非同步方法仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销消耗 适用于一个线程发放稳同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,自旋会消耗CPU 追求响应时间,同步块的执行速度非常快
重量级锁 线程竞争不会使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步时间执行速度较长

下面对对象头中涉及的几个锁进行简要介绍。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获得锁时,会在 对象头栈帧 中的锁记录里存储锁偏向的线程 ID ,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需要简单地测试下对象头里的 MarkWord 中是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,需要再测试下 MarkWord 中偏向锁的标识是否为 1 ;如果未设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的释放,需要等待全局安全点。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活,如果不处于活动状态,则将对象头设置为无锁状态;如果线程还活着,持有偏向锁的栈会被执行,遍历偏向对象的锁记录 ,栈中的锁记录和对象头的 Mark Word 要么重新偏向其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。

轻量级锁

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为01状态,是否为偏向锁为0),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 MarkWord。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程采用自旋来获得锁。在轻量级解锁时,使用原子 CAS 将 Displaced MarkWord 替换回对象头,如果成功则表示没有竞争发生,如果失败则表示当前锁存在竞争,锁会膨胀为重量级锁。因为自旋会消耗 CPU,为了避免无用的自旋,一旦膨胀为重锁,将不会再次降级。

图片说明

重锁

重量级锁依赖于操作系统的互斥量(mutex) 实现,该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作。

锁膨胀的一半情况

  • 当没有被当成锁时,这就是一个普通的对象,Mark Word 记录对象的 HashCode ,锁标志位是 01 ,是否偏向锁那一位是 0
  • 当对象被当做同步锁并有一个线程 A 抢到了锁时,锁标志位还是 01 ,但是否偏向锁那一位改成 1 ,前 23bit 记录抢到锁的线程 id ,表示进入偏向锁状态
  • 当线程 A 再次试图来获得锁时,JVM 发现同步锁对象的标志位是 01 ,是否偏向锁是 1 ,也就是偏向状态, Mark Word 中记录的线程 id 就是线程 A 自己的 id ,表示线程 A 已经获得了这个偏向锁,可以执行同步锁的代码
  • 当线程 B 试图获得这个锁时,JVM 发现同步锁处于偏向状态,但是 Mark Word 中的线程 id 记录的不是 B ,那么线程 B 会先用 CAS 操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程 A 一般不会自动释放偏向锁。如果抢锁成功,就把 Mark Word 里的线程 id 改为线程 B 的 id ,代表线程 B 获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行
  • 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。 JVM 会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是 CAS 操作,如果保存成功,代表线程抢到了同步锁,就把 Mark Word 中的锁标志位改成 00 ,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行
  • 轻量级锁抢锁失败, JVM 会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从 JDK7 开始,自旋锁默认启用,自旋次数由 JVM 决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行
  • 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为 10 。在这个状态下,未抢到锁的线程都会被阻塞

Monitor

Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Mark Word 记录了对象和锁有关的信息,当这个对象被 synchronized 关键字当成同步锁时,围绕这个锁的一系列操作都和 Mark Word 有关。Mark Word 在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit 。

synchronized的优点和缺点

  • 若干线程交叉访问同一对象的同步方法时,一定线程安全(关键字取得的锁都是对象锁)
  • 两个线程访问同一个对象的 A 方法,如果方法 A 是 synchronized 的,那么将会同步访问;如果线程 1 访问 synchronized 的 A 方法,线程 2 访问非 synchronized 的 B 方法,则会异步地访问
  • 一个 synchronized 方法/块的内部调用本类的其他 synchronized方法/块时,是永远可以得到锁的( synchronized 的可重入性)
  • 当存在父子继承关系时,子类可以通过可重入锁调用父类的同步方法
  • synchronized 当出现异常时,自动释放锁;ReentrantLock 则必须手动 unlock()
  • synchronized 同步不具有继承性。
  • 若干线程并发访问同一对象中的 synchronized(this) 同步代码块时,只有一个线程可执行。
  • 当一个线程访问 synchronized(this)时,其他线程访问同一个对象实例的所有其他 synchronized(this) 时(不管是不是同一个块)将会被阻塞,因为 synchronized 使用了同一个对象监视器
  • synchronized 加到 static 上等同于对类上锁,加到非 static 上对实例上锁
  • 大多数情况下,同步 synchronized 代码块不适用 String常量 作为锁对象,因为可能会造成死锁(https://www.cnblogs.com/zhazhaacmer/p/11052751.html) ,可以使用 new String()
  • 多线程争夺的锁对象不变,即使锁对象的属性改变,依旧是同步的
  • synchronized 不仅可以解决一个线程看到对象处于不一致的状态,还可以保证进入同步方法或同步代码块的每个线程,都看到由同一个锁保护之前所有的修改结果

volatile

volatile 是轻量级的 synchronized ,它在多处理器中保证了共享变量的可见性。如果 volatile 使用恰当的话,其使用和执行成本比 synchronized 更低,因为它不会引起线程上下文切换和调度。利用volatile 修饰的共享变量,会在对 共享变量写入 时插入一条 Lock 前缀指令,并引发下面两个事件:

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他 CPU 里缓存该数据的内存地址的数据无效

在多处理器环境下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探总线上传播的数据来检查自己缓存的值是否过期。

volatile保证内存有序性

为了保证内存可见性, Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器重排序。JMM 把内存屏障分为四类:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为3种类型:

  • (编译器)编译器优化的重排序:编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序
  • (处理器)指令级并行重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • (处理器)内存系统的重排序:由于处理器使用缓存/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

可以看书,指令重排序大致分为两类,分别是编译器重排序和处理器重排序。上述两种重排序均会导致内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序;对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。

顺序一致性模型与JMM模型的区别

  • 顺序一致性模型保证单线程内部操作按顺序执行;JMM 没有此项担保
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序;JMM 不保证所有线程看到一致的顺序
  • 顺序一致性模型保证所有类型的内存读写具有原子性;JMM 不保证对 64位 long 型和 double 型变量的写操作具有原子性

volatile读写内存的语义

  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效。线程接下来将会从主内存中读取共享变量。实质上接受了之前某个线程发出的消息(对这个 volatile 变量已进行修改)
  • 当写一个 volatile 变量时,实质上也是该线程向接下来要读这个 volatile 变量的某个线程发出了消息(对共享变量)

CountDownLatch与CyclicBarrier的区别及使用场景

粗浅的区别在于:

  • CountDownLatch是一次性的,而 CyclicBarrier 在调用 reset 之后还可以继续使用
  • CountDownLatch 计数器为减法,CyclicBarrier 为加法

高端区别在于其实现以及语义上的区别:

  • CountDownLatch : 一个线程(或者多个),等待另外 N 个线程完成某个事情之后才能执行
  • CyclicBarrier: N 个线程相互等待,任何一个线程完成之前,所有的线程都必须等待

对于 CountDownLatch 来说,重点是有一个特殊的线程,是它在等待,而另外那 N 的线程在把某个事情做完之后可以继续等待,可以终止。而对于 CyclicBarrier 来说,重点是那 N 个线程,他们之间任何一个没有完成,所有的线程都必须等待。

一个生动的比喻如下:

  • CountDownLatch 是计数器,线程完成一个就记一个,就像报数一样,只不过是递减的
  • CyclicBarrier 更像一个水闸,线程执行就想水流,在水闸处都会堵住,等到水满(线程到齐)了,才开始泄流

CountDownLatch使用案例

解析一个文件下多个 txt 文件数据,可以考虑使用多线程并行解析以提高解析效率。每一个线程解析一个文件里的数据,等到所有数据解析完毕之后再进行其他操作。

CountDownLatch源码实现

CountDownLatch实质上就是一个 AQS 计数器,通过 AQS 来实现线程的等待与唤醒。

  • 调用 CountDownLatch 的 countDown 方法时, N 会减 1 ,CountDownLatch 的 await 方法阻塞主线程直到 N 减少到 0
  • 调用 await 方法的实质是在获取同步状态,同步状态 state==0 成立,当前等待完成的点均已完成,主线程继续往下执行,否则,主线程进入等待队列自旋等待直到同步状态释放后 state==0 。有些时候主线程是不能一直自旋等待,这个时候带超时时间的 await 就派上用场了,设置超时时间,如果在指定时间内 N 个点都未完成,返回 false ,主线程不再等待,继续往下执行。

CyclicBarrier源码实现

创建 CyclicBarrier 后,每个线程调用 await 方法告诉 CyclicBarrier 自己已经到达同步点,然后当前线程被阻塞。CyclicBarrier 同样提供带超时时间的 await 和不带超时时间的 await 。整个 await 方法的核心是 dowait 方法的调用。在 dowait 的前段部分,主要完成了当所有线程都到达同步点(barrier)时,唤醒所有的等待线程,一起往下继续运行,可根据参数 barrierAction 决定优先执行的线程。在 dowait 的实现后半部分,主要实现了线程未到达同步点(barrier)时,线程进入 Condition 自旋等待,直到等待超时或者所有线程都到达 barrier 时被唤醒。

AQS

AQS是什么

AQS(抽象队列同步器)是一个用来构建 同步器 的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueueFutureTask 等等皆是基于 AQS 。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。

AQS的原理

AQS 核心思想是,如果被某线程请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套 线程阻塞等待 以及 被唤醒时锁分配 的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

private volatile int state;     //共享变量,使用 volatile 修饰保证线程可见性

状态信息通过 protected 类型的 getStatesetStatecompareAndSetState 进行操作。

//返回同步状态的当前值
protected final int getState() {  
        return state;
}
 // 设置同步状态的值
protected final void setState(int newState) { 
        state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS 的设计基于模板方法模式的。使用时候需要继承同步器并重写指定的方法,并且通常将子类推荐为定义同步组件的静态内部类,子类重写这些方法之后,AQS 工作时使用的是提供的模板方法,在这些模板方法中调用子类重写的方法。

AQS对资源的共享方式

  • 独占:只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁。
  • 共享:多个线程可同时执行,如 Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock。

AQS主要API

AQS 将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现 state 的获取 (acquire) 和释放 (release) 的逻辑代码就可以,主要包括下面方法:

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

AQS浅析1
AQS浅析2

图片说明

乐观锁与悲观锁

图片说明

乐观锁与悲观锁并不是特指某个锁,而是在并发情况下保证数据完整性的不同策略。悲观锁指的就是我们平常使用的加锁机制,它假设我们总是处于最坏的情况下,如果不加锁数据完整性就会被破坏。而乐观锁指是一种基于冲突检测的方法,检测到冲突时操作就会失败。

乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低,每次去拿数据的时候认为其他线程不会上说。在更新时会判断该段时间内有没有其他线程更新这个数据,先读取当前版本号,然后比较上一次版本号,如果一样则更新;不一样重复上述过程或放弃。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现,如下图。

CAS(Compare And Swap) 是一种有名的无锁算法。CAS 算法是乐观锁的一种实现。CAS 有 3 个操作数,内存值 V ,旧的预期值 A ,要修改的新值 B 。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B 并返回 true ,否则返回 false 。
图片说明

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,认为写多读少,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的 synchronized 关键字的实现也是悲观锁。 AQS 框架下的锁则是先尝试 CAS 乐观锁去获取锁,获取不到锁再转化为悲观锁,如 RetreenLock 。

乐观锁存在的问题

  • ABA问题
  • 循环时间长开销大。自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销
  • 只能保证一个共享变量的原子操作

线程 A 进行 CAS 逻辑,在从内存中获取到 var 值到开始进行逻辑之间,会有一个时间差;如果刚好在这个时间差内,有其他某线程对 var 做了一系列的操作,但最后又恢复了 var 的值,即:出现偷梁换柱的情况;虽然此时线程 A 仍然能 CAS 成功,但是中间多出的那些过程仍然可能引发问题。

图片说明

解决 ABA 问题的方法是打版本号或时间戳。

Java 中 CAS 操作的执行依赖于 Unsafe 类的方法。Unsafe 类存在于 sun.misc 包中,其内部方法操作可以像 C 的指针一样直接操作内存,单从名称看来就可以知道该类是非安全的,毕竟 Unsafe 拥有着类似于 C 的指针操作,因此总是不应该首先使用 Unsafe 类,Java 官方也不建议直接使用的 Unsafe 类。

悲观锁存在的问题

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题
  • 一个线程持有锁会导致其它所有需要此锁的线程挂起
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险

可重入锁

图片说明

可重入锁是一种可以再次获得自己的内部锁。当某个线程 A 已经持有了一个锁(对象锁),当线程 B 尝试进入被这个锁保护的代码段的时候,就会被阻塞。同一个线程再次进入同步代码的时候,可以使用自己已经获取到的锁,这就是可重入锁。 Java 里面 内置锁(synchronize)Lock(ReentrantLock) 都具有可重入性。当存在父子继承关系时,子类可以通过可重入锁调用父类的同步方法。自旋锁是一种不可重入锁。

可重入锁的实现:
为每个锁关联一个获取计数器和一个所有者线程,当计数值为 0 的时候,这个所就没有被任何线程只有。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取计数值置为 1 ,如果同一个线程再次获取这个锁,计数值将递增,退出一次同步代码块,计算值递减,当计数值为 0 时,这个锁就被释放。

共享锁和独占锁

  • 独占锁,当一个线程获取了锁,其它线程就不能获取到锁,必须等锁释放了,才能可能获取到锁。例如:synchronized 和 ReentrantLock
  • 共享锁,是可以多个线程获取到锁,但不是任何线程都可以共享锁。典型的就是 ReentrantReadWriteLock 里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占

公平锁与非公平锁

图片说明

图片说明

  • 公平锁在加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。
  • 非公平锁再加锁时不考虑排队等待,直接尝试获得锁,获取不到自动到对位等待。

一般来说 非公平锁 效率比 公平锁 高 5-10 倍,因为公平锁需要在多核环境下维护一个队列。 synchronized 和 ReentrantLock 中默认的 lock() 方法都是非公平锁。

公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面的线程。这种情况下相比较非公平锁多了一次挂起和唤醒。说得再详细一点,就是对于非公平锁和公平锁,这两种锁都有等待队列,但是区别在于非公平锁允许线程不先出入队而是先尝试获得锁,这一点上公平锁需要扫描等待队列,源码上也直接反映了这一点。去掉这个功能的话公平锁和非公平锁也无甚差异。但是非公平锁可能导致等待队列中的线程饿死,或者等很久才会获得锁。

自旋锁

图片说明

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待而不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自悬),等持有锁的进程释放锁后即可获得锁,这样就会避免用户线程和内核的切换和消耗。如果持有锁的进程在自旋的最大等待时间内依旧没有获取到锁,争用线程会停止自旋进入阻塞状态。

自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

自旋锁的实现原理同样也是 CAS ,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

适应性自旋锁是自旋锁的优化,自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

自旋锁的优缺点:

  • 对于锁竞争不激烈且占用锁时间非常短的代码块而言,自旋会大幅度提升性能。因为自旋的消耗会小于线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换。
  • 如果锁竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,此时不适合使用自旋锁,此时自旋只会白白占用CPU。同时有大量线程竞争一个锁,导致获取所的时间很长,自旋消耗大于线程阻塞挂起操作的消耗。

分段锁

假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap 所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

锁清除 锁粗化

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。对于一些看起来没有加锁的代码,其实隐式的加了很多锁。

通常情况下,为了保证多线程间的有效并发,会要求每个线程有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能优化。

ReetrantLock

ReetrantLock基本情况

ReentrantLock 继承接口 Lock 并实现了接口中定义的方法,它是一种可重入独占锁,支持公平锁与非公平锁,除了能完成 synchronized 所能完成的所有工作外,还提供了注入可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

ReetrantLock 的内部实现涉及到计数值、双向链表(AQS)、CAS+自旋。在 ReetrantLock 中的属性继承体系中,最终定位到 AQS 。 ReetrantLock 的大致实现为双向链表 + int 类型状态。

等待中断

ReenTrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

提供公平锁

所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock 默认情况是非公平的,可以通过
ReenTrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。

Condition

Synchronized 关键字与 wait() 和 notify()/notifyAll() 方法相结合可以实现等待/通知机制, ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。 Condition 是 JDK5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。

在使用 notify()/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现选择性通知,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个 Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而 Condition 实例的 signalAll() 方法 只会唤醒注册在该 Condition 实例中的所有等待线程。

ReetrantLock如何实现公平锁与非公平锁

先给出 ReetrantLock 的核心代码:

public class ReentrantLock implements Lock, java.io.Serializable {
    /** Synchronizer providing all implementation mechanics */
    private final Sync sync;

    abstract static class Sync extends AbstractQueuedSynchronizer {}

    static final class NonfairSync extends Sync {}

    static final class FairSync extends Sync {}

    public ReentrantLock() {}

    public ReentrantLock(boolean fair) {}
}

ReentrantLock 里面有一个内部类 Sync , Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。它有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。 ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

图片说明

通过上图中的源代码对比,公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors() 。再进入 hasQueuedPredecessors() ,可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回 true ,否则返回 false 。综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

图片说明

综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

Lock vs CAS vs synchronized

CAS与synchronized使用场景对比

  • 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能
  • 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized

synchronized 在 JDK6 之后,已经改进优化。synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于 CAS 。

Lock和synchronized的区别

"Null" Lock synchronized
可重入锁 满足 满足
可见性与互斥性 满足 满足
等待中断 支持 不支持
公平性 支持 不支持
Condition 支持 不支持
对锁的持有状态 显示获得、释放锁 隐式获得锁
并发策略 同步非阻塞 同步阻塞
代码层侧 接口 关键字
异常处理 必须在 finally 里手动解锁 自动释放锁
对锁状态的感知 支持 不支持
共享性 支持 不支持

Lock和synchronized的使用场景

  • 在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized 是很合适的。原因在于,编译程序通常会尽可能的进行优化;另外,可读性上较好
  • ReentrantLock 适合高并发的场景
  • Lock 提供一些 synchronized 所没有的特性,比如时间锁等候可中断锁等候无块结构锁多条件轮询锁
  • 与目前的 synchronized 实现相比,争用下的 ReentrantLock 实现更具可伸缩性。这意味着当许多线程都在争用同一个锁时,使用 ReentrantLock 的总体开支通常要比 synchronized 少得多。

场景参考

分布式

CAP原则

CAP 分别表示一致性可用性分区容忍性。CAP 是指在分布式系统中,上述三种特性不能完全具备,只能同时满足两种。一致性表示,分布式系统中所有的备份在同一时刻是否相同;可用性表示,当分布式系统中的部分节点宕机后,是否还能继续对外提供服务;分区容忍性表示,当节点间无法通信时,便可能产生分区,此时必须在一致性和可用性之间做出选择。

CAP原则中一致性级别

  • 强一致性:指系统中某个数据更新后,后续任何对该数据的操作都将得到更新后的值
  • 弱一致性:不保证每次都能获得最新的值
  • 最终一致性:是弱一致性的特殊模式,在经过一段时间窗口后,最终所有访问都能得到最新的值

为什么CAP不可三者兼得

当分布式节点间的网络断开时,对节点 A 进行数据更新,无法在节点 B 上进行同步。此时,B 不能提供最新的数据,即满足了分区容忍性。此时,面临两个选择:为了拿到最新的数据,进入阻塞状态,等待节点间网络恢复并同步最新数据,但是这时的可用性就被牺牲了;为了保证服务高可用,以及用户低时延反馈,将旧数据返回给用户。

CAP的取舍及实际产品

  • CA:如果不要求满足分区容忍性,则可以保证强一致性和可用性。但是放弃保证分区,则系统放弃使用分布式系统扩展性能,有违初衷。常见的 CA 系统有关系数据库 MySQL
  • CP:如果不要求满足可用性,则每个请求都要在分布式系统的各节点间保持强一致性,这可能导致同步时间的无限延长,牺牲用户体验。常见的 CP 系统有分布式数据库 RedisHBASE ,这些数据库对数据的一致性要求高
  • AP:如果不要求强一致性,则可以提高用户体验,但是会造成一定程度上的数据不一致性

Base原则

Base 分别表示基本可用软状态最终一致性。是 CAP 在一致性和可用性上权衡的结果,也是 CAP 原则逐步演化的结果。其核心理念是,放弃强一致性,保障最终一致性。基本可用指的是分布式系统在遭遇故障时,允许丧失部分可用性,但是不等价于不可用。例如,原来请求的反馈时间为0.1s,现在允许为0.5s。软转态是指,允许系统中的数据存在中间状态,并认为中间状态不影响系统的整体可用性。最终一致性,强调系统中所有的数据副本在经过一段时间的同步后,最终能够达到一个一致的状态。

分布式一致性算法Paxos

Paxos 算法解决的问题是一个分布式系统如何就某个值(决议)达成一致。一个典型的场景是,在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点执行相同的操作序列,那么他们最后能得到一个一致的状态。为保证每个节点执行相同的命令序列,需要在每一条指令上执行一个一致性算法以保证每个节点看到的指令一致。

Paxos中有三种角色:

  • Proposer:只要 Proposer 发的提案被半数以上 Acceptor 接受, Proposer 就认为该提案里的 value 被选定
  • Acceptor:只要 Accepter 接受了某个提案, Accepter 就认为该提案里的 value 被选定
  • Learner:只要 Accepter 告诉 Learner 哪个 value 被选定, Learner 就认为哪个 value 被选定

Paxos 算法分为两个阶段,具体如下:
阶段一,准 leader 确认:

  • Proposer 选择一个提案编号 N ,然后向半数以上的 Acceptor 发送编号为 N 的 Prepare 请求
  • 如果一个 Acceptor 收到一个编号为 N 的 Prepare 请求,且 N 大于该 Acceptor 已经响应过的所有 Prepare 请求的编号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给 Proposer ,同时该 Acceptor 承诺不再接受任何编号小于 N 的提案

阶段二,leader 确认:

  • 如果 Proposer 收到半数以上 Acceptor 对其发出的编号为 N 的 Prepare 请求的响应,那么它就会发送一个针对 [N,V] 提案的 Accept 请求给半数以上的 Acceptor 。注意:V 就是收到的响应中编号最大的提案的 value ,如果响应中不包含任何提案,那么 V 就由 Proposer 自己决定
  • 如果 Acceptor 收到一个针对编号为 N 的提案的 Accept 请求,只要该 Acceptor 没有对编号大于 N 的 Prepare 请求做出过响应,它就接受该提案

分布式锁

在传统单体应用单机部署的情况下,为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行可以使用 Java 并发处理相关的 API (如ReentrantLock或Synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能力。为了解决这个问题就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。分布式锁大致分为以下几类:

  • 基于数据库实现分布式锁
  • 基于缓存,例如 Redis 实现分布式锁
  • 基于 ZooKeeper 实现分布式锁

分布式锁应具有的特性

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 具备可重入特性
  • 具备锁失效机制,防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

基于数据库实现的分布式锁

基于表主键唯一做分布式锁。利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。但是这种方法存在以下问题:

  • 锁强依赖数据库的可用性,单点数据库容易挂掉
  • 锁没有失效时间,其他线程将无法获得锁
  • 锁只能是非阻塞
  • 锁不可重入
  • 锁只能是非公平锁
  • 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象

基于数据库排他锁做分布式锁。在查询语句后面增加 for update ,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。这种方法存在如下问题:

  • MySQL 可能不使用索引进行查找,当使用全表扫描时,就相当于使用全表锁
  • 使用排他锁来进行分布式锁的 lock 时,如果一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

优点:

  • 借助数据库,容易理解
  • 避免引入第三方应用,例如 Redis 或 ZooKeeper
    缺点:
  • 需要自己解决一些问题,例如超时处理、加事务
  • 性能比缓存 Redis 差一些,高并发场景表现不佳

基于Redis缓存的分布式锁的实现

在 Redis 中,可以利用一些 API 方法实现分布式锁:

  • 利用 setnx() 和 expire() 实现。调用 setnx(key, value) 方法时,如果 key 不存在,设置当前 key 成功,返回 1 ;否则表明 key 存在设置失败。调用 expire(time) 用于设置超时时间。最后执行完业务代码后,通过 delete 命令删除 key。这两个函数组合调用,可以解决分布式加锁的需求,但是一旦 setncx() 调用后宕机,将发生死锁
  • 利用 getset(key, newValue) 。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。过程如下:
1. setnx(lockkey, 当前时间+过期超时时间),如果返回 1 ,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
2. get(lockkey) 获取值 oldExpireTime,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
3. 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值 currentExpireTime 。
4. 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

优点:

  • 缓存服务都是集群部署的,可以避免单点问题
  • 性能好,实现起来较为方便
    缺点:
  • 需要维护 Redis 集群,如果要实现 RedLock 那么需要维护更多的集群
  • 失效时间的设置机制不灵活

基于ZooKeeper实现分布式锁

ZooKeeper 机制规定同一个目录下只能有一个唯一的文件名,zookeeper 上的一个 znode 看作是一把锁,通过 createznode 的方式来实现。所有客户端都去创建 /lock/${lock_name}_lock 节点,最终成功创建的那个客户端也即拥有了这把锁,创建失败的可以选择监听继续等待,还是放弃抛出异常实现独占锁。 ZooKeeper 可以创建 4 种类型的节点,分别是:

  • 持久性节点
  • 持久性顺序节点
  • 临时性节点
  • 临时性顺序节点

持久性与临时性表示 ZooKeeper 客户端断开连接后是否保留节点;顺序性节点表示创建节点时 ZooKeeper 会自动给节点进行编号。 ZooKeeper 具有监听机制,客户端注册监听它关心的目录节点,让目录节点发生变化时, ZooKeeper 会通知客户端。由于 ZooKeeper 实现分布式锁时使用的是临时节点,所以不同担心锁不释放与超时问题。此外, ZooKeeper 还能有效地防止单点问题、不可重入问题,非阻塞问题,使用较为简单。

ZooKeeper加锁解锁的具体过程参考:https://blog.csdn.net/wuzhiwei549/article/details/80692278

优点:

  • 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题
  • 较为简单的实现
    缺点:
  • 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能
  • ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上