简介

本系列为《Java并发编程的艺术》读书笔记。在原本的内容的基础上加入了自己的理解和笔记,欢迎交流!



chapter 2:Java并发机制的底层实现

之前学习Java多线程的时候,使用过

synchronized

,这种锁称为

重锁

,而

volatile

被称为

轻量锁

,具有可见性,可见性是指一个线程修改一个共享变量时,其他线程可以读取修改后的值 。

书中给出了一些术语,之后会频繁出现,作者介绍详尽,不再补充:

在这里插入图片描述



volatile关键字

在Java中,如果一个变量被该关键字修饰,那么所有线程看到这个变量的

值一定是相同的

(共享)。

//在类中声明
volatile instance = new Singleton();


// 使用工具查看生成的汇编代码,会发现在声明变量的后面多一句内容:
Lock addl $0x0, (%esp);


Lock

指令会引发CPU将指定位置的数据写回到内存中,一旦对应的数据发生了改变,所有cache中的对应的记录都会变成

脏数据



因为计算机中的高速缓存必须维持数据的版本一致

。这种协议称为

缓存一致协议



通常 来说,每个CPU有自己对应的cache,而且cache通常有多级。

当一个

Lock信号

在总线上广播时,别的CPU会拦截写回内存的地址,并去自己对应的缓存中检测是否命中,如果命中则将对应的缓存行的脏读位置为

1。总的来说,volatile实现的原则有两点:

  1. Lock指令触发处理器缓存写回到内存中;
  2. 一致性协议使得别的处理器的缓存中对应的缓存行失效,所以就必须去内存中读取值。

就目前而言,处理器的操作过程是:

所有持有该数据的处理器的cache会将对应的缓存行锁定(因为数据是被修改了,所有不允许别的处理器来访问这些脏数据),通过缓存一致性协议保证修改被写回到内存中

。该操作称为

缓存锁定



Synchronized关键字

Java中每个对象都是可以作为锁,具体情况可以划分成以下几种:

  1. 普通对象的锁是当前实例对象;
  2. 静态同步方法,锁是当前类的Class对象;
  3. 同步方法块,锁是

    Synchronized

    括号内的对象,即1,2。

JVM中的线程想要访问同步代码块必须获取锁,基于的是

Monitor

对象。代码的块的同步是使用

monitorenter



monitorexit

指令实现的,

这两个指令会在Java编译成字节码的时候被插入在指定的位置

同步代码块在编译时会在开头插入monitorenter,在结束处和异常处插入monitorexit。



Java对象头

每个对象都会有对象头,用来存储一些

元数据



synchronized锁的相关信息就存储在对象头。

之所以每个对象都可以作为一个锁,是因为在java对象头中保存了对应的信息。前面提到的CAS算法其实就用来修改Java对象头的。

数组对象的对象头包括3个字长,比非数组对象多一字长用于表示数组的长度。

在这里插入图片描述

Mark word的内容如下:

  1. 对象的HashCode
  2. 分代年龄
  3. 锁标记位



锁的升级与比较

在Java 1.6之后,锁一共有四种状态,对应的优先级从低到高:



1. 无锁

即没有加锁。

在这里插入图片描述



2. 偏向锁:只有一个线程访问锁

在这里插入图片描述

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的

锁记录



存储锁偏向的线程ID



以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。


如果发生了线程对数据的争用,那么偏向锁就会撤销



注意:偏向锁只是

加速了一个线程多次访问一个同步块

,且没有发生争用,适用于线程对锁的竞争度不高的情况。

偏向锁的作用主要是加速

线程争用情况不严重的场景

,锁标记指示当前对象持有的偏向锁,此时线程会检查markword中的线程ID是否是自己,如果不是自己那么去检查是否设置了偏向锁标志:

  1. 设置为1:使用CAS将当前线程id修改到线程ID:

    1. 成功:获取偏向锁; 此时 :

      TID|Epoch|对象分代年龄|1|01
    2. 失败:撤销偏向锁;此时:

      null|Epoch|对象分代年龄|0|01

      。此后偏向锁失效,因为发生了锁争用。
  2. 设置为0:那么就去竞争锁,此时偏向锁失效,偏向锁的撤销必须是在安全点。

    在这里插入图片描述



3. 轻量级锁:适用于争用情况不严重的场景



加锁
在这里插入图片描述

线程在访问同步代码块之前,会在当前的线程的系统栈中创建

存储锁记录的空间(多个锁)

每访问一个同步代码块就会将这个锁对象(A对象)的

mark word

(理解为对象唯一标识)复制到该空间中,并使用CAS(原子性)操作将A对象的

mark word

中的对应的字段修改成

当前的线程锁记录空间的地址



这里的理解就是,一旦发现当前的

mark word

被修改了,那么别的线程使用CAS操作就无法修改,会进入自旋或者锁膨胀

这个过程就是将对象A的mark word(唯一标识)拿走,放到自己的私有区域(线程栈),并告诉别人现在这个锁被获取了(因为原本存储mark word的位置存储到了一个锁空间的地址)。

如果成功则获取锁,如果失败则使用自旋的方式不断尝试获取锁;



解锁

解锁就很好理解了。和加锁的过程相反,就是把原本拿走的

mark word

还回去,表示我用完了,当别的线程再次访问这个对象时,会发现这个位置的值确实是对象本身的

mark word

,此时就会获取成功。

如果此时有线程在争用这个锁,那么轻量级锁就会失效,升级成重量锁,那么拿到锁的线程在使用CAS解锁就会失败,

此时释放锁的线程需要唤醒因为该锁而阻塞的线程



在这里插入图片描述



4. 重量级锁:多个线程同时访问一把锁

由于锁的争用且自旋一段时间无法获取锁,此时线程就会将当前的锁修改成重量级锁。重锁会让当前线程进入

阻塞状态

,会被加入到

同步队列

中。



原子性操作

先明确一些术语:

在这里插入图片描述

首先我们来看一下处理器(32位的IA-32处理器)是如何实现原子性操作的:


  1. 对总线进行加锁

    :处理器提供一个LOCK信号,当有一个

    LOCK

    信号被输出到总线上时,其他处理器的请求将被阻塞。

    但是CPU和内存之间的通信也会被阻塞


  2. 对缓存机进行加锁



    总线加锁使得其他的处理器不可以操作其他内存地址的数据

    。如果一个内存地址的数据被缓存到了cache中,那么在执行锁操作期间,当发生了写回动作时会去更新缓存行中的数据,利用缓存一致性来保证数据的可靠性。

  3. 缓存一致性

    : 当CPU写数据时,如果发现操作的变量是共享变量,

    即在其他CPU中也存在该变量的副本

    ,会发出信号通知其他CPU

    将该变量的缓存行置为无效状态

    ,因此当其他CPU需要读取这个变量时,发现自己缓存中的缓存该变量的缓存行是无效的,那么它就会重新从内存中读取。

作者详尽的表述了Java如何实现原子性操作的,总结起来就是锁和循环的CAS操作。不断地使用CAS操作尝试去修改状态,如果成功则代表获取到了锁。

CAS存在的问题和解决办法:


  1. ABA问题

    :引入版本号;

  2. 循环时间过长

    :使用pause指令

    1. 延迟流水线的执行;
    2. 避免在退出循环时因为内存的冲突引发流水线清空;

  3. 无法保证多个变量的共享操作

    1. 使用锁;
    2. 将多个共享变量



版权声明:本文为qq_38684427原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_38684427/article/details/118088470