欢迎光临散文网 会员登陆 & 注册

DEVLOG 1.3 Java多线程 -- 锁、阻塞、唤醒

2022-01-03 21:37 作者:房顶上的铝皮水塔  | 我要投稿

参考文章:

  1. Java中的线程和操作系统中的线程的关系

    https://zhuanlan.zhihu.com/p/133275094

  2. Java多线程基础 :https://www.liaoxuefeng.com/wiki/1252599548343744/1304521607217185

  3. Java中的琐事 -- 美团技术团队:

    https://tech.meituan.com/2018/11/15/java-lock.html

本文是对Java多线程中涉及到锁的一些基本知识的一些总结,如果有出错之处,欢迎指正~

文章目录:

#1 Java中的多线程

    a. Java中的线程的生命周期

    b.Java中的线程和操作系统中的线程的关系

#2 锁 阻塞 唤醒

    a. 锁的使用举例

    b. synchronized 的实现

#3 锁的分类

    a. 乐观锁 悲观锁

    b. 可重入锁 不可重入锁

PART1 Java中的多线程

进程是操作系统提出的伟大的概念。简单而言的理解,我们使用的各项应用都可以看成一个进程,我们可以一遍听音乐一边打游戏还可以进行其他的任务。这些进程看似互不冲突,但是实际上是通过CPU的时间分片执行的。这些进程快速切换,给我们一种同时执行的错觉。

这种切换我们称为进程的上下文切换,通常而言进程的上下文切换需要保存被切换掉的进程的寄存器程序计数器等等的信息,同时加载切换到的进程的寄存器和程序计数器信息。

而线程,根据维基百科的定义,是操作系统可以执行运算的最小单元。线程通常被包含在进行内部执行。借用廖雪峰老师的一张图, 可能的模式是这样子:

    a. Java中的线程的生命周期

Java天然的在JDK中提供了线程的支持,同时线程存在下面的生命周期状态。

yield表示当前running状态的线程放弃CPU,会转到就绪态;如果当前线程创建了子线程的话,调用join会阻塞自身等待子线程的执行。


    b. Java中的线程和操作系统中的线程的关系

这里主要参考了这篇文章:https://zhuanlan.zhihu.com/p/133275094

因为Java是一种跨平台的语言,所以Java在上层(应用层面)的开发我们不需要考虑操作系统的的细节。平台之间的差异通过JVM实现屏蔽掉了。

在Linux中,Java的线程是通过Linux的pthread实现的。

PARTII 锁、阻塞 唤醒

a. 锁的使用举例

上面Java线程生命周期的例子看起来比较抽象,下面结合一个经典的生产者消费者的实例来解释这些有Java提供的API接口的使用:

生产者向中间仓库Storage中写入消息,生产者生产的速度慢于消费者消费的速度。

消费者读取Storage中的数据,而且是一次性读完。

为了解释这个例子,首先得引入锁】的概念。

因为生产者和消费者都需要访问Storage中的消息,好比两个人通知争抢一块蛋糕,或者两个人同时行走在一根独木桥上,如果不控制两个人的顺序,会发生斗争或者是没有一个人能够过桥的情况。 这种需要争抢的资源我们通常称作【互斥资源】。

锁在计算机语言中表示对于这种互斥资源的限制,如果想要访问互斥资源,必须需要获取锁。Java通过内置的synchronized关键字提供了基本的锁的支持。synchronized方法块施加在storage对象上,并且生产者和消费者都持有storage,这样可以保证两方只有一方能够访问storage。

因为同时启动了两个线程Producer和Consumer,所以访问我们可以理解为是同时执行的,这样当Producer获取storage的锁的时候,Consumer只能进入阻塞状态。阻塞状态的结束,要么等到Producer的synchronized方法块的完结,或者是Producer在方法块中调用notify/nofityAll唤醒等待在storage的【锁】上的Consumer。  

当Consumer先于Producer获取storage的锁时,storage中没有任何message,此时调用storage.wait,Consumer自行进入阻塞状态的同时会释放storage的【锁】。这里要明确地记住,调用storage.wait会释放锁。

在具有更多线程的情况下, 为了使得CPU利用率最大,假如当前CPU上运行的线程被阻塞,CPU是不会等到这个线程拿到锁,重新变成就绪态的,如果有其他线程,其他线程也会在这个CPU上执行。

所以通俗来讲,【锁】表示对于互斥资源的控制,阻塞主要是当没有获取到互斥资源时的排队行为;除了等待持有锁的线程结束资源的使用,还可以通过唤醒的方式,将互斥资源交给阻塞的其他线程。尤其是当两个线程同时写一个互斥资源的时候,使用锁控制访问就显得尤其重要!

b. synchronized 的实现

synchronized的实现有赖于底层一种名为monitor机制。对于任何一个Java对象而言,这个对象在内存中的布局都包含这三个部分:

填充数据是为了保证在访问内存的时候更加快速;实例变量就是存放了实例的属性信息。

和synchronized的实现有关的部分在于对象头中:

对象头分成两个部分,一个是Mark Word另外一个是Class Metadata Address。后者用于确定这个实例属于那个类,mark word则包含指向monitor的指针,后者表示锁的类型(主要有四种)。

mark word虽然有32位,但是会根据锁的类型不同,其中每一位表示的意义也有所区别。

monitor这种机制在HotSpot虚拟机中通过Cpp的ObjectMonitor类实现:

每一个对象都会使用一个ObjectMonitor C++类和它关联。 下面我们通过图示来说明Monitor机制具体的工作细节(reference:https://blog.csdn.net/javazejian/article/details/72828483)

当有很多线程在访问这个对象的锁时,首先会进入对象实例(ObjectMonitor)的_EntryList中,当如果成功获取对象锁,会被移除,同时ObjectMonitor中的_owner会设置为获取到锁的线程。如果对象实例使用wait方法,获取锁的线程会进入阻塞状态,进入_WaitSet;否则当完成之后释放锁并且退出。

正是因为Java中的内置锁synchronized免不了和对象关联, 所以每个类在继承Object的同时也继承了wait notify等等方法。


PART III Java中锁的分类

a. 乐观锁和悲观锁 

通过对于对象是否在某个线程持有阶段被其他线程修改,锁可以分成乐观锁和悲观锁。

乐观锁认为线程在持有数据的阶段不会被其他的线程修改(如果发生修改就执行对应的策略);悲观锁认为持有数据时,其他的线程一定会修改这个数据,因此需要对数据加锁,控制访问顺序。

先介绍悲观锁,再来说乐观锁。

悲观锁

Java中的synchronized和Lock类都是悲观锁,使用Lock相对于synchronized关键字而言,锁的粒度更细。

乐观锁

在介绍乐观锁之前,首先要说明两个概念:原子操作和CAS

Conception#1: 原子操作

原子操作则是在操作系统层面上不会被中断的操作。比如i++这个操作就不是原子操作,他可以分成三部分,取出i,计算+1的值,写回i对应的地址,如果在执行i++的第二部分到第三部分之间,有其他的线程读取i的值,读取的结果还是i加1之前的值。所以原子操作就是为了避免出现这种情况。

Conception#2: CAS

CAS操作就是一种原子操作,在Java API层面上,JDK提供了Unsafe类的一些方法,但是具体的实现都是native的,有赖于底层的操作系统。这里说明一下,所谓Unsafe类就是提供了一些可以和操作系统底层交互的方法,但是由于这样的方法可能有不安全的问题,所以命名为【Unsafe】。

CAS操作就是Compare and Set的缩写,主要接受这样的三个参数:

V 目标变量的内存地址; A 需要比较的值,B需要写入的新的值。如果目标值和A相等,就将B写到对应的目标地址,并且返回true,否则返回false。

下面我们继续说一下乐观锁。乐观锁的具体实现可以参考AtomicInteger类:

可以看到,乐观锁并不是使用了某种提供的具体的API,这里通过do-while循环使当前线程不断访问目标地址的值,如果这个值等于A,则修改为B。

这种使用while,do-while形式的“锁”我们称之为【自旋锁】。在多线程条件下,线程请求另外一种互斥资源时,如果没有拿到锁,这个线程就会被阻塞,但是上下文切换时存在很高的代价的。使用自旋锁是为了避免因为使用阻塞而出现上下文切换,从而造成更大的时间浪费。 

乐观锁Vs悲观锁

首先,自旋锁也存在一些问题:

  1. ABA问题: 如果在CAS+循环的迭代过程中,目标位置的变量从A->B->A,此时CAS还是会认为目标位置的变量没有变化。但是这样是存在隐患的,比如使用CAS修改堆栈栈顶的值,但是站内元素已经发生改变了。 对于ABA问题,可以让每次修改都对应版本号,如1A->2B->3A。

  2. 乐观锁能否进行访问控制的幅度有限,通常只能管一个变量。使用Lock或者是synchronized可以锁住一整个代码块。

当锁的竞争不激烈时,可以使用乐观锁,因为悲观锁会让其他的线程无法同时访问;当竞争激烈时,CAS会导致重试次数较多,白白浪费CPU资源。


JVM也针对并发中的锁问题进行了一些优化,锁可以从偏向锁升级为重量级锁。

在实际开发过程中,多次获取锁的极有可能是同一个线程,这就不存在多个线程相互竞争。

偏向锁的mark word中存储了线程的ID,如果是同一个线程,会检测mark word中是否存放着此线程的ID。如果有其他的锁尝试竞争偏向锁,偏向锁会升级为轻量级锁。

当其他线程访问轻量级锁时,这个线程会保持自旋等待,当第三个线程访问时,轻量级锁升级为重量级锁。

当轻量级锁升级为重量级锁时,所有的线程都必须阻塞访问。

b. 可重入锁不可重入锁

可重入锁指的是,对于同一个线程访问互斥资源,线程不需要等待自身释放资源


 对于这个例子而言,Thread1先调用了do1,然后再调用do2,如果synchronized不是可重入锁,则需要等待Thread1释放锁,这样就产生了【死锁】。


ReentrantLock就是可重入锁,可以看出在源码中,ReentrantLock通过检查当前的线程来判断是否可重入。

总结:

  1. 本文首先简单描述了Java中的多线程的概念,并且和操作系统层面的多线程作对比。在Linux中,JVM的线程基于Linux的pthread。

  2. 在第二小节中,通过消费者生产者的例子重点说明了线程生命周期中的阻塞和运行。Java天然就支持多线程,这种多线程的支持不光是体现在继承自Object类的wait和notify上,所有的对象都具有对象头。在JVM层,monitor机制负责管理、记录阻塞的线程队列。同时简单介绍了锁优化的一些知识,JDK6中引入的锁优化可以从偏向锁开始逐步过渡到重量级锁。

  3. 最后一小节主要介绍了两种锁的分类。根据数据的假定数据是否被修改可以分成乐观锁和悲观锁。悲观锁主要是使用synchronized和各种Lock类;乐观锁主要介绍了自旋锁,自旋锁就是在有限的时间片还没结束时,也不会让自身的线程阻塞。如果在若干次迭代中,持有锁的线程释放锁,自旋锁可以得到,并且修改值。 通过锁的可重入性可以分成可重入和不可重入锁。


DEVLOG 1.3 Java多线程 -- 锁、阻塞、唤醒的评论 (共 条)

分享到微博请遵守国家法律