Java并发 — Java线程模型和锁机制

Java并发 — Java线程模型和锁机制

一. 写在前面:Java线程模型

1. 什么是线程模型

因为Java运行在JVM中,而JVM运行在各个操作系统上。因此当JVM需要进行各种线程创建和回收相关的操作时,需要调用操作系统的相关接口。这也就是说,JVM线程和操作系统线程存在一种映射关系,这其中的对应关系的规范和协议就是Java线程模型。

2. 为什么需要线程模型

为什么需要线程模型呢,有些人说我们直接调用操作系统的接口来创建和回收线程不是更加直接吗?这个问题的答案其实很容易理解,这就好比说我们为什么不直接用汇编语言而是使用而简单更容易上手的高级语言来进行开发一样,这是一种自下而上的抽象方式。JVM对不同操作系统的原生线程进行了高级抽象,使各个开发者可以屏蔽下层操作系统级的差异和细节而只用专注上层的开发。

3. 线程模型的分类

线程模型主要有三类,分别是:一对一,多对一,多对多。这里顺便纠正一个错误,在Linux中,线程的实现方式为轻量级进程,因为Linux没有专门为线程实现专门的结构和调度算法,本质还是进程,和进程一样是使用clone系统调用,区别是传递的参数不同,而这也导致进程之间不共享内存地址,轻量级进程之间共享一个进程组的内存地址。因此,“线程属于进程”的说法并不完全准确。

一对一模型

这种线程模型主要是在用户线程和内核线程之间建立了一对一的关系,这样的对应方式简单好用,缺点是用户态线程的阻塞和唤醒会直接反应到操作系统上,导致内核态的频繁切换,降低性能。但是一些语言引入了CAS来避免一部分情况下的状态切换,比如Java就引入了AQS(AbstractQueuedSynchronizer)来减少使用内核级别锁。另一个缺点是Linux内核能创建的线程数量有限,所以会在一定程度上限制并发量。大部分JVM都采用这种模型。

多对一模型

多个用户线程映射到了一个内核线程上,用户线程的调度由用户空间完成。这样能够有效提供并发量上限,而且在用户空间完成的调度能有效提升性能,但它的缺点是如果一个用户线程进行了内核调用并且阻塞的话,其他线程在此期间都无法进行内核调用。JAVA早期使用了这种模型。

多对多模型

多对多模型是为了中和解决上述两种模型的缺点,但其实现难度较高。比如当前Go语言的GMP线程模型就是基于多对多的方式来实现的,这也是能够使用goroutine来实现高并发的原因,而Java的loom项目也在进行这方面的探索。

总结来说,一对一模型简单实用,易于控制,非常适合实用。而多对多模型是一种趋势,未来可能会更加常见和成熟。

二. 线程同步:Java的锁机制

1. 什么是锁

在并发环境下,多个线程会对同一个资源进行争抢,可能导致资源不一致的问题。锁就是为了解决这样的问题而引入的,在Java中也同样,通过一种抽象的锁来进行资源的锁定。

2. Java的锁结构

之前有讲过JVM内存分区的内容,这里不展开叙述,其中线程共享的区域有Java堆和方法区,主要内容是对象,类信息,常量,静态变量的数据。这其中的内容在多线程竞争时,可能发生难以预料的异常,因此需要锁来进行限制。

在Java中,每个Object都拥有一把锁,这个锁存在Java的对象头中,对此首先要明确Java中对象的结构:

// 缺少图片

如图所示,其中填充字节是为了满足“Java对象大小必须是8bit的倍数”的虚拟机规范(在HotSpot中为8byte)。对象数据是在初始化对象时设置的属性,方法等内容。而对象头则储存了对象的一些运行时信息,包含了MarkWord和Class Pointer,是一些额外的储存开销。
Class Pointer是一个指针,指向当前对象类型所在方法区的类型数据。
MarkWord存储了很多与对象运行时信息有关的数据,如hashcode,锁状态标志,偏向锁ID等。

可以看到,锁标志位是最后两位,代表了无锁,偏向锁,轻量级锁和重量级锁四种状态。

首先在Java中synchronized关键字可以用来同步线程,其对应字节码是monitorenter和monitorexit来完成线程同步。
关于monitor可以理解成右图:这是一个只能容纳一名客人的房间,在Entry Set中排队等待进入房间,状态为 “Waiting for monitor entry”,进入房间的线程为Active状态,如果线程执行完成,会退出。而如果这时因为一些其他资源或判断调用wait暂时释放资源,会进入到Wait Set进行等待,状态为 “in Object.wait()”。而后Entry Set中的线程在房间空后可以获取资源进入房间,而Wait Set中的线程需要别的线程在该对象上调用了 notify() 或者 notifyAll() ,“ Wait Set”队列中线程才得到机会去竞争。

synchronized存在性能问题,因为monitor实际是依赖操作系统的mutex lock来实现的。Java线程是对操作系统线程的映射,因此执行挂起和唤醒线程时,都要切换操作系统内核态,该操作比较重量级,会非常消耗时间和系统资源。

但是从Java6开始,Java对synchronized进行了性能的优化,引入了偏向锁,轻量级锁,由此总共有4中锁状态。分别是无锁,偏向锁,轻量级锁和重量级锁,对应MarkWord的四种锁状态,且这四种状态只能升级不能降级,接下来讲解这四种状态。

3. Java的锁

Ⅰ. 无锁

顾名思义,这里就是没有加锁来对对象进行锁定。这里存在两种情况:
1. 不存在竞争,也就是说这个对象不会出现在多线程场景下或者说虽然有多线程情况但是不会出现竞争抢占的问题,这种情况下确实无需加锁进行控制,直接进行访问就可以了。
2. 存在竞争但是不使用锁,也就是说虽然有竞争,但是还是希望通过锁以外的手段来控制资源访问,比如在多个线程同时修改某资源时,一次只让一个线程修改成功而其他线程不断重试,这也就是CAS。通过CAS可以实现无锁编程,但编程难度较高。而不通过mutex的情况下,无锁的效率是很高的,但这并不意味着无锁能完全代替有锁。

Ⅱ. 偏向锁

假如一个对象被加锁了,但其实际运行过程只有一个线程会获取这把锁,那么最理想的情况是不通过mutex或CAS,而这个对象能够认识这个线程,从而只要是这个线程过来,这个对象就把锁交出去。这样对象对这个线程的偏向关系称为偏向锁。其实现很简单,当锁标志位是01时,判断倒数第三个bit是否为1,如果为1处在偏向锁状态下再读前54个bit来获取线程Id,通过判断线程id若相等则直接调用资源,若不等则表明有多个线程在竞争锁,此时会升级成轻量级锁。

Ⅲ. 轻量级锁

在偏向锁状态下,Java是通过MarkWord中的线程ID来找到占用这个锁的线程,当锁升级到轻量级锁时又是如何的呢?可以看到在轻量级锁状态下“线程ID”这个字段已经替换成了“指向栈中锁记录的指针”,我们来研究下其中的奥秘:
首先通过锁标志位为00确定当前为轻量级锁状态,之后线程会在虚拟机栈中开辟一块名为LockRecord的空间,而这个LockRecord中存放了对象MarkWord的副本和Owner指针。线程通过CAS尝试获取锁,一旦获得便会复制MarkWord到LoclokRecord中,并且将该Owner指针指向该对象。另一方面,对象MarkWord中前54个bit会生成一个指针,指向该LockRecord。这样就实现了对象和线程的绑定,让他们互相知道了对方的位置,于是该对象就被锁定了,获取了该对象的线程就可以执行对应任务。

如果此时有其他线程也要获取该对象,那么其他线程会自旋等待,即不断循环尝试看目标对象的锁有没有被释放,并尝试获取。自旋避免被操作系统挂起和阻塞,不需要进行系统中断和现场恢复,因此其效率更高,但长时间的自旋会浪费CPU资源,由此产生了“适应性自旋”的优化,即自旋时间不固定,由上一次在同一个对象上的自旋时间和锁状态来决定。
那么如果自旋等待的线程超过1个,轻量级锁会上升为重量级锁。

Ⅳ. 重量级锁

如果一个对象被标记为重量级锁,那就需要用monitor对线程进行控制,此时会完全锁定资源,对线程的管控也会更加严格。具体monitor的控制方式上文已经介绍。