Skip to content

Concurrency

并行和并发有什么区别?

  • 并行是指两个或者多个事件在同一时刻发生(时间点);而并发是指两个或多个事件在同一时间间隔内发生(时间段)
  • 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
  • 并发在一台处理器上“同时”处理多个任务,并行是在多台处理器上同时处理多个任务。如hadoop分布式集群。

同步和异步的区别?

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

并发编程的三个重要特性分别是什么?

并发编程为了保证数据的安全,必须满足以下三个特性:

  • 原子性

原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰; 基本数据类型的访问都具备原子性,例外就是 long 和 double,虚拟机将没有被 volatile 修饰的 64 位数据操作划分为两次 32 位操作。如果应用场景需要更大范围的原子性保证,JMM 还提供了 lock 和 unlock 操作满足需求,尽管 JVM 没有把这两种操作直接开放给用户使用,但是提供了更高层次的字节码指令 monitorenter 和 monitorexit,这两个字节码指令反映到 Java 代码中就是 synchronized。

  • 可见性

可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。 除了 volatile 外,synchronized 和 final 也可以保证可见性。同步块可见性由"对一个变量执行 unlock 前必须先把此变量同步回主内存,即先执行 store 和 write"这条规则获得。

final 的可见性指:被 final 修饰的字段在构造方法中一旦初始化完成,并且构造方法没有把 this 引用传递出去,那么其他线程就能看到 final 字段的值。

  • 有序性

如果在本地线程内观察,所有操作都是有序的(“线程内表现为串行”(Within-Thread As-If-Serial Semantics));

如果在一个线程中观察另一个线程,所有操作都是无序的(“指令重排序”现象和“线程工作内存与主内存同步延迟”现象)。 Java语言通过提供了volatile和synchronized两个关键字 和 happen-before 原则一起来来保证线程之间操作的有序性:

volatile关键字本身就包含了禁止指令重排序的语义;synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

为什么要使用多线程呢?

  • 更多的处理器核心,程序使用多线程技术,将计算逻辑分配到多个处理器核心上,就会显著减少程序的处理时间,并且随着更多处理器核心的加入而变得更有效率。
  • 更快的响应时间,使用多线程技术,即将数据一致性不强的操作派发给其他线程处理。这样做的好处是响应用户请求的线程能够尽可能快地处理完成,缩短了响应时间,提升了用户体验。
  • 更好的编程模型,使用多线程能简化程序的结构,使程序便于理解和维护。一个非常复杂的进程可以分成多个线程来执行。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

对线程安全的理解?

《Java Concurrency In Partice》的作者 Brian Goetz 对 “线程安全” 有一个比较恰当的定义:

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为以下五类

  • 不可变 在Java语言里面(特指JDK 5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。Java 语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,如果你不明白这句话,不妨想一想 java.lang.String 类的对象,它是一个典型的不可变对象,我们调用它的 substring()、replace() 和 concat() 这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
  • 绝对线程安全 一个类要达到 “不管运行环境如何,调用者都不需要任何额外的同步措施” 通常需要付出很大的,甚至有时候是不切实际的代价。jdk api中标注自己是线程安全的类中的大多数,都不是绝对安全的,而是相对安全。
  • 相对线程安全 相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。java中大部分的所谓线程安全类都是这个类型的,例如Vector,HashTable,Collections.synchronizedCollection()方法包装的集合等。
  • 线程兼容 线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
  • 线程对立 线程对立指的是无论调用端是否采取了同步措施,都无法在多线程环境中保证数据的安全。通常是有害的,应尽量避免。一个例子就是Thread类的suspend()跟resume()方法。两个线程同时持有一个对象,一个尝试去中断线程,一个尝试去恢复线程,无论调用时是否进行了同步,目标线程都存在死锁的风险。也正是这个原因,这俩方法已经被jdk废弃了。

单核 CPU 上运行多个线程效率一定会高吗?

单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:CPU 密集型和 IO 密集型。CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。

在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。

因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。

什么是上下文切换?

上下文是指某一时间点 CPU 寄存器和程序计数器的内容。

寄存器 是 CPU 内部的数量较少但是速度很快的内存(与之对应的是 CPU 外部相对较慢的 RAM 主内存)。寄存器通过对常用值(通常是运算的中间值)的快速访问来提高计算机程序运行的速度

程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统。

上下文切换指的是内核在CPU上对进程或者线程进行切换。上下文切换过程中的信息被保存在进程控制块(PCB,Process Control Block)/线程控制块(TCB,Thread Control Block)中。PCB又被称作切换帧(SwitchFrame)。上下文切换的信息会一直被保存在CPU的内存中,直到被再次使用。

上下文切换可以认为是内核在 CPU 上对于进程/线程进行以下的活动:

  1. **挂起一个进程/**线程,将这个进程在 CPU 寄存器中的状态(上下文)存储于内存中的PCB/TCB;
  2. **恢复一个进程/**线程,在内存中检索下一个进程/线程的上下文并将其在CPU 的寄存器中恢复;
  3. 跳转到程序计数器所指向的位置(即跳转到进程/线程被中断时的代码行),以恢复该进程。

线程上下文切换和进程上下文切换一个最主要的区别是与进程相比,线程之间的上下文切换地址空间保持不变,即不需要切换当前使用的页表。

引起线程上下文切换的原因如下:

  • 当前正在执行的任务完成,系统的CPU正常调度下一个任务。
  • 当前正在执行的任务遇到I/O等阻塞操作,调度器挂起此任务,继续调度下一个任务。
  • 多个任务并发抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续调度下一个任务。
  • 用户的代码挂起当前任务,比如线程执行yield()方法,让出CPU。
  • 硬件中断。

上下文切换会带来 直接和间接 两种因素影响程序性能的消耗。

  • 直接消耗:指的是CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉;
  • 间接消耗:指的是多核的cache之间得共享数据, 间接消耗对于程序的影响要看线程工作区操作数据的大小;

上下文切换会导致额外的开销,因此减少上下文切换次数便可以提高多线程程序的运行效率。但上下文切换又分为2种:

  1. 让步式上下文切换:指执行线程主动释放CPU,与锁竞争严重程度成正比,可通过减少锁竞争来避免;
  2. 抢占式上下文切换:指线程因分配的时间片用尽而被迫放弃CPU或者被其他优先级更高的线程所抢占,一般由于线程数大于CPU可用核心数引起,可通过调整线程数,适当减少线程数来避免。

所以,减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程

  1. 无锁并发:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash取模分段,不同的线程处理不同段的数据;
  2. CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁;
  3. 最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态;
  4. 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换;

死锁、活锁、饥饿分别是什么意思?

死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁发生的条件

  • 互斥条件:一个资源每次只能被一个进程使用;
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺;
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系;

活锁:是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。

饥饿:是指如果线程T1占用了资源R,线程T2又请求封锁R,于是T2等待。T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T4的请求…,T2可能永远等待。

如何避免死锁?

  • 破坏互斥条件:使资源同时访问而非互斥使用,就没有进程会阻塞在资源上,从而不发生死锁。
  • 破坏请求和保持条件:采用静态分配的方式,静态分配的方式是指进程必须在执行之前就申请需要的全部资源,且直至所要的资源全部得到满足后才开始执行,只要有一个资源得不到分配,也不给 这个进程分配其他的资源。
  • 破坏不剥夺条件:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源,但是只 适用于内存和处理器资源。
  • 破坏循环等待条件:给系统的所有资源编号,规定进程请求所需资源的顺序必须按照资源的编号依次进行。

Java 线程实现的原理是怎样的?

实现线程主要有三种方式:使用内核线程实现,使用用户线程实现,使用用户线程加轻量级进程混合实现**。**

  • 内核线程实现(1:1实现)

内核线程(KLT,Kernel-Level Thread),直接由操作系统内核(Kernel,即内核)支持的线程。由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核叫做多线程内核。程序一般不会去直接使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(LWP),即通常意义上的线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程与内核线程之间 1:1 关系称为一对一的线程模型。

优点:在多处理器系统,内核能够同时调度同一进程中的多个线程并发执行;如果一个进程的某个线程被阻塞,内核可以调度该进程的其他线程运行,也可以调度其他进程的线程运行;线程的切换比较快,切换开销小;内核本身也可以采用多线程技术,可以提高系统的执行速度和效率;

缺点:对于用户线程的调度,其模式切换开销比较大,在同一个进程中,从一个线程切换到另一个线程时,需要从用户态转到核心态实现,这是因为,线程运行在用户态,而线程调度和管理是在内核实现的,因此系统开销较大;

  • 用户线程实现(1:N实现)

使用用户线程实现的方式被称为1:N实现广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(UserThread,UT)的一种,从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量。这种进程与用户线程之间1:N的关系称为一对多的线程模型。用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通常都比较复杂,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等。

  • 混合实现(N:M实现)

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

Java线程如何实现并不受Java虚拟机规范的约束。Java线程在早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(GreenThreads)的用户线程实现的,但从JDK 1.3起,“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。Solaris平台的HotSpot虚拟机,由于操作系统的线程特性本来就可以同时支持1:1(通过Bound Threads或Alternate Libthread实现)及N:M(通过LWP/ThreadBased Synchronization实现)的线程模型,因此Solaris版的HotSpot也对应提供了两个平台专有的虚拟机参数,即**-XX:+UseLWPSynchronization**(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型。

Java线程调度的方式是什么?

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种:

  • 协同式调度

线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。

协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。

它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

  • 抢占式调度

线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。

Java使用的线程调度方式就是抢占式调度

对Java线程优先级的理解是什么?

每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的。线程优先级是一个int变量(从1到10),1代表最低优先级,10代表最高优先级,默认为5, 通过thread.setPriority()用来设定线程的优先级。线程优先级具有继承性。a线程启动b线程,b线程的优先级和a线程的优先级是一样的。Java中对线程所设置的优先级只是给操作系统一个建议。而真正的调用顺序,是由操作系统的线程调度算法决定的。

线程的生命周期有哪些状态?

通用的线程生命周期基本上可以用下图这个“五态模型”来描述:

  • 创建状态(NEW)。指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  • 就绪状态(Ready)。指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  • 运行状态(Running)。当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU 的线程的状态就转换成了运行状态。
  • 阻塞状态(Blocked)。运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  • 死亡状态。线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

Java 语言中线程共有六种状态,分别是:

  1. NEW(初始化状态),新建状态,线程被创建且未启动,此时还未调用start()方法。
  2. RUNNABLE(可运行 / 运行状态),Java 将操作系统中的就绪和运行两种状态统称为 RUNNABLE,此时线程有可能在等待时间片,也有可能在执行。
  3. WAITING(无时限等待),等待状态,处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
  • 没有设置Timeout参数的Object::wait()方法;
  • 没有设置Timeout参数的Thread::join()方法;
  • LockSupport::park()方法。
  1. TIMED_WAITING(有时限等待),限期等待状态,处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
  • Thread::sleep()方法;
  • 设置了Timeout参数的Object::wait()方法;
  • 设置了Timeout参数的Thread::join()方法;
  • LockSupport::parkNanos()方法;
  • LockSupport::parkUntil()方法。
  1. BLOCKED(阻塞状态),线程被阻塞,在程序等待进入同步区域的时候,线程将进入这种状态。“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。
  2. TERMINATED(终止状态),终止状态,表示当前线程已执行完毕或异常退出。

为什么JVM 没有区分Ready 和 Running 这两种状态呢?

现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

线程的创建方式有哪些?

  • 继承 Thread 类并重写 run 方法。实现简单,但不符合里氏替换原则,不可以继承其他类。
  • 实现 Runnable 接口并重写 run 方法。避免了单继承局限性,编程更加灵活,实现解耦。
  • 实现 Callable 接口并重写 call 方法。可以获取线程执行结果的返回值,并且可以抛出异常。
  • 线程池

说一下 runnable 和 callable 有什么区别?

  • Callable可以在任务结束后提供一个返回值,Runnable无法提供这个功能。
  • Callable中的call()方法可以抛出异常,而Runnable的run()方法不能抛出异常。
  • 运行Callable可以拿到一个Future对象,Future对象表示异步计算的结果。当调用Future的get()方法以获取结果时,当前线程就会阻塞,直到call()方法结束返回结果。

什么是守护线程?

守护线程是一种支持型线程,可以通过 setDaemon(true) 将线程设置为守护线程,但必须在线程启动前设置。在Daemon线程中产生的新线程也是Daemon的。

**JVM 在没有非守护线程时需要立即退出,所有守护线程都将立即终止,所以JVM 退出时守护线程中的 finally 块不一定执行。**守护线程相当于后台管理者 比如 : 进行内存回收,垃圾清理等工作。

线程通信的方式有哪些?

线程的通信机制有两种:

  • 共享内存即是将堆设置为线程间共享的,通过堆中的对象实现数据共享,这样其他线程能够知道某个线程修改了某个数据。可能线程安全问题等,但是优势便在于速度更快节约内存,Java使用这种方式。
  • 消息传递是每个线程都私有自己的数据空间,当需要线程通信的时候,便需要一个线程显示的给另外一个线程发送具体的消息,这样能够让多线程更加安全,但是私有的线程空间和消息传递带来的是需要给通信线程间都拷贝相同的对象,变量等,并且可能会带来效率问题。

线程的常用方法有哪些?

  • Thread.sleep(long millis),当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
  • Thread.yield(),当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。
  • thread.join()/thread.join(long millis),当前线程里调用其它线程T的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程T执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态,因为join是基于wait实现的
  • obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
  • obj.notify(),唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
  • LockSupport.park()/LockSupport.parkNanos(long nanos),LockSupport.parkUntil(long deadlines), 当前线程进入WAITING/TIMED_WAITING状态。对比wait方法,不需要获得锁就可以让线程进入WAITING/TIMED_WAITING状态,需要通过LockSupport.unpark(Thread thread)唤醒。

为什么通常使用while循环来调用wait方法?

java
 			// 列表为空就等待
            while (synchedList.isEmpty()) {
                synchedList.wait();
            }

主要解决虚假唤醒问题:

  • 当条件不满足时,当前线程调用wait等待条件成立。
  • wait方法是当前线程进入等待状态,等待被唤醒。
  • 当其他获取到该对象锁的线程释放锁时,上面的线程有可能被意外唤醒,但是此时上面线程是不满足条件的,导致它破坏了被锁保护的约束关系,引起意外后果。

sleep() 和 wait() 有什么区别?

相同点:它会使此线程暂停执行指定时间,而把CPU执行机会让给其他线程。

不同点:

  • sleep()方法是Thread类的静态方法,wait()是Object超类的成员方法;
  • sleep()方法线程不会释放对象锁,而调用wait()方法后,它就进入到一个和该对象相关的等待池,同时线程会释放掉它所占用的锁。
  • sleep()方法用来控制线程自身流程的不需要被唤醒,休眠一段时间后自动退出阻塞,而wait()用于线程间的通信需要针对此对象调用notify/notifyAll()方法,也可以给它指定一个时间,自动醒来。
  • sleep()方法当线程休眠时间结束后,会返回到可运行状态,不是运行状态,不能保证该线程睡眠到期后就开始执行,如果要到运行状态还需要等待CPU调度执行,而wait()被唤醒后进入对象锁定池。
  • wait()它必须放在同步控制方法或者同步语句块中使用,而sleep()方法则可以放在任何地方使用。
  • sleep()方法必须捕获异常,而wait、notify以及notifyall不需要捕获异常。在sleep的过程中,有可能被其他对象调用它的interrupt(),产生InterruptedException异常。

sleep()方法与yield()方法有什么区别?

  • sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会,而yield()方法只会给相同优先级或更高优先级的线程以运行的机会。
  • 线程执行sleep()方法后会转入阻塞状态,执行sleep() 方法的线程在指定的时间内肯定不会被执行,而yield()方法只是使当前线程重新回到可执行状态,所以执行yield()方法的线程有可能在进入到可执行状态后马上又被执行。
  • sleep()方法声明抛出InterruptedException,而yield()方法没有声明任何异常。
  • sleep()方法比yield()方法(跟操作系统相关)具有更好的可移植性。

为什么 Thread的sleep()方法与 yield()方法是静态的?

Thread类的sleep()和yield()方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。

notify()和 notifyAll()有什么区别?

  • 调用了notify()后只有一个线程被随机唤醒会由等待池进入锁池,而**notifyAll()**会依次(FILO)调用notify唤醒该对象等待池内的中所有线程。
  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。
  • notify() 是对notifyAll()的一个优化,但它有很精确的应用场景,并且要求正确使用。不然可能导致死锁。正确的场景应该是 WaitSet中等待的是相同的条件,唤醒任一个都能正确处理接下来的事项,如果唤醒的线程无法正确处理,务必确保继续notify()下一个线程,并且自身需要重新回到WaitSet中.

为什么 wait(),notify()和 notifyAll()必须要在同步方法或同步代码块中调用?

所有的这些方法都需要线程持有对象的锁,这如果尝试在未获取对象锁时调用这三个方法,那么会抛出异常。

wait()方法和notify()/notifyAll()方法在放弃对象监视器时有什么区别

wait()方法立即释放对象监视器(锁),notify()/notifyAll()方法并不会马上会释放锁,会继续往下执行,直到执行完synchronized代码块的代码或是中途遇到wait() ,再次释放锁。

线程的 run()和 start()有什么区别?

每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。start()方法来启动一个线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码; 这时此线程是处于就绪状态并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

终止线程的方法有哪些?

在Java语言中,可以使用stop()方法来终止线程的执行。当用stop()来终止线程时,它会释放已经锁定的所有监视资源。如果当前任何一个受这些监视资源保护的对象处于一个不一致的状态,其他线程将会“看”到这个不一致的状态,这可能会导致程序执行的不确定性,并且这种问题很难被定位。鉴于以上方法的不安全性,同时 stop() 方法已经被标记为过时,已经不建议使用以上两种方法来终止线程了。一般建议采用的方法是让线程自行结束进入Dead状态。一个线程进入Dead状态,即执行完run()方法,也就是说,如果想要停止一个线程的执行,就要提供某种方式让线程能够自动结束run()方法的执行。在实现时,可以通过设置一个**volatile flag**标志来控制循环是否执行,通过这种方法来让线程离开run()方法从而终止线程。

Thread.interrupted()方法的作用是什么?

Java多线程的中断机制是用内部标识来实现的。调用该方法的线程的状态为将被置为”中断”状态。线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。

如何捕获一个线程抛出的异常?

可以使用公共静态接口Thread.UncaughtExceptionHandler完成,该接口是当线程因未捕获的异常而突然终止时调用的处理程序接口。当一个线程由于未捕获的异常而即将终止时,Java虚拟机将使用getUncaughtExceptionHandler()来查询线程的 UncaughtExceptionHandler,并将调用该处理程序的 uncaughtException方法,并将该线程和异常作为参数传递。如果某个线程没有 显式设置其UncaughtExceptionHandler,则其ThreadGroup对象将充当其 UncaughtExceptionHandler。如果ThreadGroup对象没有处理异常的特殊要求,它可以将调用转发到默认的未捕获异常处理程序。可通过setUncaughtExceptionHandler()方法设置自定义处理器

什么时 Fork/Join框架?

Fork/Join框架是Java 7提供的一个基于分治任务模型和工作窃取算法用于并行执行任务的框架。Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask。ForkJoinTask 是一个抽象类,最核心的方法是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask 有两个子类RecursiveAction 和 RecursiveTask,它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute(),区别是 RecursiveAction定义的compute() 没有返回值,而RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的 时候,需要定义子类去扩展。

ForkJoinPool 本质上也是一个生产者 - 消费者的实现,ForkJoinPool内部有多个任务队列,当通过invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。如果工作线程对应的任务队列空了,ForkJoinPool 支持“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。

使用ForkJoin将相同的计算任务通过多线程的进行执行,从而能提高数据的计算速度,需要注意:

  • 使用这种多线程带来的数据共享问题,在处理结果的合并的时候如果涉及到数据共享的问题,尽可能使用并发容器。
  • 在使用JVM的时候要考虑OOM的问题,如果任务处理时间非常耗时,并且处理的数据非常大的时候。会造成OOM。
  • ForkJoin也是通过多线程的方式进行处理任务。当数据量不是特别大的时候,我们没有必要使用ForkJoin。因为多线程会涉及到上下文的切换。所以数据量不大的时候使用串行比使用多线程快。

分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。对于一个比较大的任务,可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。当某些线程完成了自己的任务,就会而其他线程对应的队列里还有任务等待处理。这是它就会去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

多线程同步有几种实现方法?

  • synchronized关键字 在Java语言中,每个对象都有一个对象锁与之相关联,该锁表明对象在任何时候只允许被一个线程所拥有,当一个线程调用对象的一段synchronized代码时,首先需要获取这个锁然后去执行相应的代码,执行结束后,释放锁。synchronized关键字主要有两种用法(synchronized方法和synchronized块),此外该关键字还可以作用于静态方法、类或某个实例,但这都对程序的效率有很大的影响。

  • synchronized方法:在方法的声明前加入synchronized关键字。例如:public synchronized void mutiThreadAccess():只要把多个线程访问的资源的操作放到mutiThreadAccess方法中,就能够保证这个方法在同一时刻只能被一个线程来访问,从而保证了多线程访问的安全性。然而,当一个方法的方法体规模非常大时,把该方法声明为synchronized会大大影响程序的执行效率。

  • synchronized块:为了提高程序的执行效率,Java语言提供了synchronized块。可以把任意的代码段声明为synchronized,也可以指定上锁的对象有非常高的灵活性。

  • wait与notify 当使用synchronized来修饰某个共享资源的时候,如果线程A1在执行synchronized代码另外一个线程A2也要同时执行同一对象的同一synchronized代码时,线程A2将要等到线程A1执行完成后,才能继续执行。在这种情况下,可以使用wait方法和notify方法。在synchronized代码被执行期间,线程可以调用对象的wait方法,释放对象锁,进入等待状态,并且可以调用notify方法或notifyAll方法通知正在等待的其他线程,notify方法仅唤醒一个线程(等待队列中的第一个线程),并允许它去获得锁,而notifyAll方法唤醒所有等待这个对象的线程,并允许它们去获得锁(并不是让所有唤醒线程都获取到锁,而是让它们去竞争)。

  • Lock JDK5新增加了Lock接口以及它的一个实现类ReentrantLock(重入锁),Lock也可以用来实现多线程的同步,具体而言,它提供了如下的一些方法来实现多线程的同步:

  1. lock()。以阻塞的方式来获取锁,也就是说,如果获取到了锁,则立即返回,如果其他线程持有锁,当前线程等待,直到获取锁后返回。
  2. tryLock()。以非阻塞的方式获取锁。只是尝试性地去获取一下锁,如果获取到锁,则立即返回true,否则,立即返回false。
  3. tryLock(long timeout, TimeUnit unit)。如果获取了锁,立即返回true;否则,会等待参数给定的时间单元,在等待的过程中,如果获取了锁,就返回true,如果等待超时,则返回false。
  4. lockInterruptibly()。如果获取了锁,则立即返回,如果没有获取锁,则当前线程处于休眠状态,直到获得锁,或者当前线程被其他线程中断(会收到InterruptedException异常)。它与lock()方法最大的区别在于:如果lock()方法获取不到锁,则会一直处于阻塞状态,且会忽略interrupt()方法。如果把lock.lockInterruptibly()替换为lock.lock(),编译器将会提示lock.lock()catch代码块无效,因为lock.lock()不会抛出异常,由此可见,lock()方法会忽略interrupt()引发的异常。

Exchanger 是什么?

Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据, 如果第一个线程先执行exchange方法,它会一直等待第二个线程也执行exchange,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。因此使用Exchanger的重点是成对的线程使用exchange()方法,当有一对线程达到了同步点,就会进行交换数据。因此该工具类的线程对象是成对的。

Exchanger类提供了两个方法,String exchange(V x):用于交换,启动交换并等待另一个线程调用exchange方法;String exchange(V x,long timeout,TimeUnit unit):用于交换,启动交换并等待另一个线程调用exchange,并且设置最大等待时间,当等待时间超过timeout便停止等待。

乐观锁与悲观锁

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改了,具体方法可以使用版本号机制或 CAS 算法。

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。不过,大量失败重试的问题也是可以解决的,像我们前面提到的LongAdder空间换时间的方式就解决了这个问题。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(java.util.concurrent.atomic包下面的原子变量类)。

如何实现乐观锁?

乐观锁一般会使用版本号机制或 CAS 算法实现。

  • 版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

  • CAS 算法

CAS 表示 Compare And Swap,比较并交换,CAS 需要三个操作数V:要更新的变量值(Var);E:预期值(Expected);N:拟写入的新值(New)。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。但不管是否更新都会返回 V 的旧值,这些处理过程是原子操作,执行期间不会被其他线程打断。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作

plain
/**
	*  CAS
  * @param o         包含要修改field的对象
  * @param offset    对象中某field的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

乐观锁存在哪些问题?

  • ABA问题

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但可能存在潜藏的问题。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference,原子更新带有版本号的引用类型,通过控制变量值的版本来解决 ABA 问题。public boolean compareAndSet(V expectedReference,V newReference,int expectedStamp,int newStamp)

  • 循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
  • 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

synchronized 关键字

synchronized 是什么?有什么用?

synchronized 是 Java 中的一个关键字,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

构造方法可以用 synchronized 修饰么?

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 底层原理?

每个对象都有一个关联的 monitor,使用 synchronized 时 JVM会根据使用环境找到对象的 monitor,根据 monitor 的状态进行加解锁的判断。成功加锁就成为该 monitor 的唯一持有者,monitor 在被释放前不能再被其他线程获取。

同步代码块使用 monitorentermonitorexit 这两个字节码指令获取和释放 monitor。这两个字节码指令都需要一个引用类型的参数指明要锁定和解锁的对象。对于同步普通方法,锁是当前实例对象;对于静态同步方法,锁是当前类的 Class 对象;对于同步方法块,锁是 synchronized(obj){}括号里的对象,obj 不能为null

执行 monitorenter 指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁(可重入锁),就把锁的计数器加 1,执行 monitorexit 指令时会将锁计数器减 1。一旦计数器为 0 锁随即就被释放。

被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。其他线程无法强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

假设有两个线程 A、B 竞争 monitor,当 A 竞争到锁时会将 monitor 中的 owner 设置为 A,把 B 阻塞并放到等待资源的 ContentionList 队列。ContentionList中的部分线程会进入EntryList,EntryList 中的线程会被指定为 OnDeck 竞争候选者,如果获得了锁资源将进入Owner状态,释放锁后进入**!Owner状态。被阻塞的线程会进入WaitSet**。持有锁是一个重量级的操作。**Java 线程是映射到操作系统的内核线程上的,如果要阻塞或唤醒一条线程,需要操作系统帮忙完成,不可避免用户态到核心态的转换。**这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized(操作系统层面的互斥,重量级锁) 效率低的原因。

不公平的原因

所有收到锁请求的线程首先自旋,如果通过自旋也没有获取锁将被放入 ContentionList,该做法对于已经进入队列的线程不公平。为了防止 ContentionList 尾部的元素被大量线程进行 CAS 访问影响性能,Owner 线程会在释放锁时将 ContentionList 的部分线程移动到 EntryList 并指定某个线程为 OnDeck 线程,该行为叫做竞争切换,牺牲了公平性但提高了性能。

JVM 对于同步方法和同步代码块的处理方式不同。对于同步方法,JVM 采用ACC_SYNCHRONIZED 标记符来实现同步。对于同步代码块。JVM 采用 monitorenter、monitorexit 两个指令来实现同步。

ACC_SYNCHRONIZED :方法级的同步是隐式的。

同步方法的常量池中会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会检查是否有 ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。如果在方法执行过程中发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

monitorenter 跟 monitorexit

可以把执行 monitorenter 指令理解为加锁,执行 monitorexit 理解为释放锁。

每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0,当一个线程获得锁(执行 monitorenter )后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行 monitorexit 指令)的时候,计数器再自减。当计数器为 0 的时候。锁将被释放,其他线程便可以获得锁。

结论:同步方法和同步代码块底层都是通过 monitor 来实现同步的。区别:同步方式是通过方法中的 access_flags 中设置 ACC_SYNCHRONIZED 标志来实现,同步代码块是通过 monitorenter 和 monitorexit 来实现。

monitor 解析

每个对象都与一个 monitor 相关联,而 monitor 可以被线程拥有或释放。对于一个 synchronized 修饰的方法(代码块)来说:

当多个线程同时访问该方法,那么这些线程会先被放进_EntryList 队列,此时线程处于 blocked 状态;

当一个线程获取到了对象的 monitor 后,那么就可以进入 running 状态,执行方法块,此时,ObjectMonitor 对象的_owner 指向当前线程,_count 加 1 表示当前对象锁被一个线程获取;

当 running 状态的线程调用 wait() 方法,那么当前线程释放 monitor 对象,进入 waiting 状态,ObjectMonitor 对象的_owner 变为 null,_count 减 1,同时线程进入_WaitSet 队列,直到有线程调用 notify() 方法唤醒该线程,则该线程进入_EntryList 队列,竞争到锁再进入_owner区;

如果当前线程执行完毕,那么也释放 monitor 对象,ObjectMonitor 对象的_owner 变为 null,_count 减 1。

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态(具体可看CXUAN 写的 OS 哦),

JVM 锁优化技术有哪些?

HotSpot虚拟机开发团队花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

  • 自旋锁

同步对性能最大的影响是阻塞,挂起和恢复线程的操作都需要转入内核态完成。许多应用上共享数据的锁定只会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。如果机器有多个处理器核心,可以让后面请求锁的线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待只需让线程执行一个忙循环,这项技术就是自旋锁。自旋锁从JDK6开始默认开启。自旋不能代替阻塞,虽然避免了线程切换开销,但要占用处理器时间,如果锁被占用的时间很短,自旋的效果就会非常好,反之只会白白消耗处理器资源。如果自旋超过了限定的次数仍然没有成功获得锁,就应挂起线程,自旋默认限定次数是 10,可使用参数**-XX:PreBlockSpin**更改

  • 自适应自旋

JDK6 对自旋锁进行了优化,自旋时间不再固定,而是由前一次的自旋时间及锁拥有者的状态决定。**如果在同一个锁上,自旋刚刚成功获得过锁且持有锁的线程正在运行,虚拟机会认为这次自旋也很可能成功,进而允许自旋持续更久。如果自旋很少成功,以后获取锁时将可能直接省略掉自旋,避免浪费处理器资源。**有了自适应自旋,随着程序运行时间的增长,虚拟机对程序锁的状况预测就会越来越精准。

  • 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

  • 锁粗化

原则上需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中进行同步,这是为了使等待锁的线程尽快拿到锁。但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之外的,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把同步的范围扩展到整个操作序列的外部。

  • 偏向锁

偏向锁是为了在没有竞争的情况下减少锁开销,锁会偏向于第一个获得它的线程,如果在执行过程中锁一直没有被其他线程获取,则持有偏向锁的线程将不需要进行同步。

偏向锁的获取:当锁对象第一次被线程获取时,虚拟机将会在栈帧中记录当前线程ID,并把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式,同时使用 CAS 把获取到锁的线程ID记录在对象的 Mark Word 中。如果CAS 成功,持有偏向锁的线程以后每次进入锁相关的同步块都不再进行任何同步操作。一旦有其他线程尝试获取锁,偏向模式立即结束,根据锁对象是否处于锁定状态决定是否撤销偏向,后续同步按照轻量级锁那样执行。

偏向锁的撤销:偏向锁在其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

使用偏向锁注意:

当对象进入偏向状态的时候,MarkWord大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity HashCode),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,**当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。**在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

  • 轻量级锁

轻量级锁是为了在没有竞争的前提下减少重量级锁使用操作系统互斥量产生的性能消耗。在代码即将进入同步块时,如果同步对象没有被锁定,虚拟机将在当前线程的栈帧中建立一个锁记录空间,存储锁对象目前 Mark Word 的拷贝。然后虚拟机使用 CAS 尝试把对象的 Mark Word 更新为指向锁记录的指针,如果更新成功即代表该线程拥有了锁,锁标志位将转变为 00,表示处于轻量级锁定状态。如果更新失败就意味着至少存在一条线程与当前线程竞争。虚拟机检查对象的 Mark Word 是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了锁,直接进入同步块继续执行,否则说明锁对象已经被其他线程抢占。自旋方式获取锁,达到一定自旋次数仍未获取到锁,或如果出现两条以上线程争用同一个锁,轻量级锁就不再有效,将膨胀为重量级锁,锁标志状态变为 10,此时Mark Word 存储的就是指向重量级锁的指针,后面等待锁的线程也必须阻塞。

解锁同样通过 CAS 进行,如果对象 Mark Word 仍然指向线程的锁记录,就用 CAS 把对象当前的 Mark Word 和线程复制的 Mark Word 替换回来。假如替换成功同步过程就顺利完成了,如果失败则说明有其他线程尝试过获取该锁,就要在释放锁的同时唤醒被挂起的线程

多线程锁的升级原理是什么?

锁共有4种状态,级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,这种策略是为了提高获得锁和释放锁的效率。为了尽量避免使用重量级锁(操作系统层面的互斥),JVM首先会尝试偏向锁(启用参数-XX:+UseBiased Locking,默认开启),偏向锁会尝试使用CAS操作来获得锁,若CAS操作失败,偏向模式就马上宣告结束,转而去获取轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。

偏向锁、轻量级锁和重量级锁的区别?

优点缺点适用场景
偏向锁加解锁不需要额外消耗,和执行非同步方法比仅存在纳秒级差距如果存在锁竞争会带来额外锁撤销的消耗适用只有一个线程访问同步代码块的场景
轻量级锁**竞争线程不阻塞,程序响应速度快。**对于“绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销如果线程始终得不到锁会**自旋消耗 CPU。**如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。适用追求响应时间、同步代码块执行快的场景。
重量级锁线程竞争不使用自旋不消耗CPU线程会阻塞,响应时间慢适应追求吞吐量、同步代码块执行慢的场景。

synchronized 和 volatile 的区别是什么?

  • volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  • volatile仅能实现变量的可见性不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

基于 MIT 许可发布