zl程序教程

您现在的位置是:首页 >  Java

当前栏目

《深入了解java虚拟机》高效并发读书笔记——Java内存模型,线程,线程安全 与锁优化

2023-04-18 12:33:05 时间

系列文章目录和关于我

本文主要参考《深入了解java虚拟机》高效并发章节
关于锁升级,偏向锁,轻量级锁参考《Java并发编程的艺术》
关于线程安全和线程安全的程度参考了《Java并发编程实战》
图片参考https://www.processon.com/u/5dee0443e4b093b9f775065c#pc

一丶Java内存模型

1.概述

多任务处理已经是操作系统的必备技能,计算机被要求同时做好几件事情,不仅是由于计算机计算能力强大了,还因为cpu的计算能力和存储以及通信子系统的速度差异太大了(指cpu工作的时候大部分时间花费在网络io,磁盘io上)所以人们开始让处理器同时进行多个任务而不是浪费时间等待io操作的完成,从而提高TPS(每秒事务处理数)

2.硬件的效率和缓存一致性

  • 解决cpu和内存运算能力差距巨大的问题

    为了解决CPU和存储子系统的存在几个数量级的问题,现代计算机系统引入了多层的高速缓冲作为内存和处理器之间的缓冲(有点类似于使用redis提高系统的访问速度,当我们系统的TPS受限于数据的io时,常常把热点数据放在基于内存的redis从而提高TPS)把运算需要数据复制到缓存,提高运算速度,当运算结束后再从缓存同步到内存,从而使处理器不必等待缓慢的内存读写了

  • 高速缓存引入的新问题

    高速缓存的引入确实很好的解决了处理器和内存速度的问题,但是同时也引入了新的问题——缓存一致性:多处理器系统中,每个处理器拥有自己的高速缓存且共享主内存,当多个处理器的运算涉及到同一主内存的时候,可能存在缓存不一致的问题(ABC三个处理器,最开始缓存数据为1,后续各自分别自加1,2,3,最后需要写回主内存,这时候出现缓存不一致的问题)

  • 指令重排

    为了使处理器内部运算单元可以被充分利用,处理器可能会对输入的代码进行乱序执行的优化,处理器在计算之后把乱序执行结果重组,保证结果和程序员定义的代码顺序执行结果相同,但是并不保证程序中每一个语句的计算先后顺序与输入代码中顺序一致。所以如果存在一个计算任务依赖另一个任务的中间结果的时候将无法依靠代码的顺序来进行保证,Java虚拟机的即时编译器也存在这样的指令重排技术

3.Java内存模型

屏蔽了操作系统的差异,保证java代码在不同的操作系统上并发完全正常,Java内存模型简称为JMM

3.1主内存和工作内存

JMM定义了程序中共享变量的访问规则,关注于虚拟机把共享变量存储到内存和从内存取值的底层细节。(共享变量指的是线程共享的字段,常包括实例字段,静态字段,构成数对象的元素,但是不包括局部变量和方法参数,这里的局部变量是reference类型,虽然它指向的对象在堆中是线程共享的但是reference本身是在栈的局部变量中,是线程私有的)。 JMM规定了:

  1. 所有变量存储在主内存,每条线程还有自己的工作内存(类似高速缓存)工作内存保存该线程使用变量的主内存的副本(不一定是复制整个对象,也许是这个对象被使用的某些字段,即使是volatile变量也存在线程工作内存的副本),
  2. 线程对变量的操作必须在工作内存中进行,而不能直接操作主内存
  3. 不同线程不能直接访问对方工作内存中的变量,线程中的变量传递必须通过主内存来完成

image

3.2内存间交互操作

3.2.1 八种内存交互的操作

JMM规定了一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存的实现细节,并且定义了8中类似的操作,Java虚拟机保证以下操作都是原子的不可再分的(这里64位的double类型,long类型在某写平台可能load,store,read,write并不保证原子性,也就意味着可能存在半读半写的情况,但是无需过于关注这一点)

  • lock:作用于主内存的变量,把变量标识位线程独占的状态
  • unlock: 作用于主内存变量,表示把处于锁定状态的变量释放出来,释放后的变量才可以被其他线程lock
  • read: 从主内存读取变量传输到线程工作内存,以便后续load操作
  • load: 作用于工作内存变量,把read读取到主内存的变量放入工作内存的变量副本中
  • use:作用于工作内存变量,表示把工作内存中的变量传递给执行引擎
  • assign: 作用于工作内存的变量,表示从执行引擎接收到的值赋值给工作内存中的变量
  • store:作用于工作内存中的变量,表示把工作内存中的变量传送到主内存中,以便后续write操作
  • write: 作用主内存的变量,把store操作从工作内存中得到的值放入到主内存的变量
JMM要求一个变量从主内存拷贝工作内存,必须顺序的执行read load
要把变量从工作内存同步到主内存,也要顺序执行store write
但是两个命令之间可以插入其他命令
到这里我们可以简单分析下为什么i++这种操作线程不安全
首先i一开始位于主内存,被多个线程使用read 和load 从主内存复制到工作内存,
然后每一个线程使用use进行自增操作,
然后assign 赋值给线程私有的工作内存,
然后每一个线程使用store把工作内存中值传送到主内存,
然后使用write操作写到主内存

虽然这每一步都是原子性的,但是合在一起就不是原子性的,
也就是说线程A write到主内存的时候,线程B也进行了write 造成都写入了2,
线程C也没有在线程A写回主内存后再拉去i的值进行自增,
依旧使用的是工作内存中的i
3.2.2 八种基本操作必须满足的规则
  • read和 load,store 和write 不可单独操作,也就是是从主内存读取变量到工作内存后续笔记存在load操作让工作保存变量的副本,同样从工作内存传送到主内存后续也必须存在write操作让主内存写入工作内存中的值到主内存。
  • 不允许线程丢弃最近的assign操作,也就是说工作内存中改变了变量,必须要同步回主内存
  • 不允许一个线程无任何原因(没有发生assign操作)把数据从工作内存同步回主内存
  • 一个变量只可以从主内存中诞生,不允许在工作内存中直接使用一个未被初始化的(load 或者assign)的变量,也就是对一个变量use 和store操作之前必须先执行assign操作和load操作
  • 一个变量在同一个时刻只允许一个线程对其进行lock操作,但是lock操作可以被一个线程执行多次,但是lock多少次也需要unlock多少次
  • 如果对一个变量指向了lock操作,那么会清空工作内存中的此变量值,执行引擎使用这个变量之前,必须重新执行load 或assign来初始化变量的值
  • 如果一个变量没有被lock 那么也不能被unlock,A线程不能unlock B线程lock的变量
  • 对一个变量指向unlock之前,必须把这个变量同步会主内存中(store然后write)

3.1 volatile

3.1.1 volatile 的特性

volatile是java提供的轻量级同步机制,当一个变量被定义成volatile后,它会具备以下两个特性

  • 对所有线程的可见性,即任何一个线程修改了volatile修饰的变量,对于其他线程都是可以立即得知(普通变量一个线程修改之后必须同步会主内存,然后另外一个线程从主内存重新读取才可以得知变化)
  • 禁止指令重排序优化,普通的变量只能保证该方法执行的时候所有依赖赋值结果的地方都能正确的结果,表现为"线程内表现为串行的语义"

3.1.2 线程可见性和禁止指令重排是如何实现的

  • 线程可见性

    volatile修饰的变量,在赋值后会多执行一个lock add$0xx,(%esp) 的操作,lock前缀的指令会导致将当前工作内存此变量写回到主内存写回内存的操作会是其他CPU中的缓存了此变量的数据无效,每个处理通过嗅探总线上传播的数据来判断自己的缓存数据是否过期,如果过期当处理器需要对数据进行修改的时候将从主内存中获取最新的数据

  • 禁止指令重排序

    lock add$0xx,(%esp) 把修改同步到主内存意味着前面指令的操作以及执行完成,也就是说lock之前的指令不可在lock指令之后,从而实现重排序无法越过内存屏障的效果

    这里我们说下为什么双重if+synchronizated为什么要把引用使用volatile修饰
    创建一个对象实例,可以分为三步:
    1.分配对象内存。
    2.调用构造器方法,执行初始化。
    3.将对象引用赋值给变量。
    
    如果不适应volatile 可能出现被重排为
    1.分配对象内存。
    2.将对象引用赋值给变量。
    3.调用构造器方法,执行初始化。
    
    假设这个线程A正在执行new Instance()下执行倒2,
    已经改变了instance的指向
    这个时候线程B也调用getInstance 将导致线程B获得还没有执行初始化的对象实例
    
    volatile在双重if+synchronizated的单例实际是起到了禁止指令重排的作用
    插入`lock add$0xx,(%esp)`保证了1,2,3都完成执行完,1,2,3可以进行重排,但是1,2,3必须都在`lock add$0xx,(%esp)`的前面执行
    

4 先行发生原则

4.1先行发生原则是什么:

先行发生原子是我们判断数据是否存在竞争,线程是否安全的有用手段。先行发生是java内存模型中定义两项操作之前的偏序关系,并不意味着时间上的先后,而是说先行发生的操作对后续操作是可见的,比如我们说A先行发生于B,那么意味着A操作造成的影响对B是可见的

4.2 Java内存模型下的先行发生原则

  • 程序次序规则:指一个线程内,按照控制六顺序,书写在前面的操作必然是对书写在后面的操作可见的
  • 管程锁定规则:unlock操作先行发生于后面对同一个锁的lock操作(后面是时间上的先后)
  • volatile 变量规则:同一个volatile变量的写先行发生于后面对此变量的读(后面是时间上的先后)
  • 线程启动规则:Thread的start方法先行发生于此线程的任何动作
  • 线程终止规则:线程中所有操作都先行发生于对此线程的终止检测,所以Thread::isAlive 可以检查当前线程是否终止
  • 对象终结规则: 一个线程的初始化完成先行发生于finalize方法的开始
  • 传递性:A先行发生于B,B先行发生于C那么A先行发生于C

4.3先行发生原则的使用

private int value;
public void setValue(int value){
	this.value=value;
}
public int getValue(){
    return value;
}

如果线程A在10:20:01 调用了setValue(1),线程B在10:20:02调用了getValue()那么线程B得到的是什么昵,显然是无法确定的也许是0,也许是1,因为无法确定线程A刷新工作内存到主内存和线程B重新获取主内存中值的先后顺序。

  1. 使用volatile修饰value

    套用volatile 变量规则:同一个volatile变量的写先行发生于后面对此变量的读,所以线程A写入是先于线程B的读取,所以B可以正确拿到值

  2. 使用synchronized修饰方法

    套用管程锁定规则:unlock操作先行发生于后面对同一个锁的lock操作(后面是时间上的先后),A释放锁在B拿到锁之前,B也可以成功拿到值

同样我们还可以使用基于AQS实现的ReentrantLock(见我的笔记《JUC源码学习笔记1——AQS独占模式和ReentrantLock》),我的理解是ReentrantLock套用的是volatile 变量规则,加锁解锁其实是修改state这个volatile变量,然后volatile可以防止指令重排,对value的设置肯定是位于锁获取和释放之间的

二丶Java与线程

1.java线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址,文件io)又可以独立调度。对于hotspot虚拟机来说,Java线程都是映射到一个操作系统原生线程来实现,而且中间没有额外的间接结构,所以hotspot不会干预线程调度的(只能通过线程优先级给操作系统调度的建议),何时冻结线程,给线程分配多少处理器执行时间,线程安排给哪个处理器核心执行都是由操作系统完成的且全权决定的。

2.java线程的调度

线程的调度是指系统为线程分配处理器使用权的过程,调度方式主要有两种:协同式调度(使用多少时间由线程本身控制)抢占式——每个线程由系统来分配执行时间,线程的切换不由线程本身决定,但是线程可以主动让出执行时间比如Thread::yield()方法,但是想主动获取处理器执行时间,线程本身无法做到,抢占式调度避免了协同式调度线程处理器执行时间不可控制的问题

三丶线程安全

1.线程安全的概念

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

线程安全的对象封装了必要的正确性保障手段,调用方无须过多担心多线程问题

2.线程安全程度

2.1.不可变

不可变对象一定时线程安全的,无论时方法的实现或者还是方法的调用者,都不需要过多的关注线程安全问题,比如说String(抛开反射修改char数组的奇葩行为)大部分方法都是生产一个新的String对象,以及Integer等。在java中,如果一个多线程共享的数据是一个final修饰的基本类型,那么必定不会再这个基本类型上存在线程安全问题,但是如果是一个引用类型那么需要我们自己自行保证线程安全。

2.2 绝对线程安全

不管运行时环境如何,调用者都不需要额外的同步措施的多线是绝对线程安全的对象。这个绝对是比较难以理解的,比如Vector使用synchronized修饰方法,那么就是线程绝对安全的么?不是我们只能保证单独的操作是线程安全的,但是比如先contain查看是否包含然后add这种复合操作任然需要我们自己加锁保证。这里的绝对是说无论我如何使用当前这个类那么都是线程安全的

2.3相对线程安全

这种就是我们常常说的线程安全,只需要保证这种对象单次的操作是线程安全的,不需要任何额外手段保证线程安全,但是一些连续的符合方法调用任然需要自行保证线程安全

2.4 线程兼容

线程兼容是对象本身不是线程安全的,但是可以通过在调用端使用一些手段保证线程安全,那么就可以保证这个对象在并发中是安全的。这就是我们常说的线程不安全

2.5 线程对立

不论调用端是否采取同步措施,都无法保证线程安全的对象。比如Thread的suspend(),resume,如果两个线程分别调用另外一个线程的这两个方法,都存在死锁的风险。

当线程A使用锁L,如果线程A处于suspend状态,而另一个线程B,需要L这把锁才能resume线程A,因为线程A处理suspend状态,是不会释放L锁,所以线程B线获取不到这个锁,而一直处于争夺L锁的状态,最终导致死锁。

3.如何实现线程安全

1.互斥同步

最常见的线程安全手段,同步的意思时多个线程并发访问共享数据时,保证共享数据同一个时刻只能被一个或一些线程访问到。互斥是实现同步的一种方式,临界区,互斥量,信号量是常见的互斥方式。

临界区:保证在某一时刻只有一个线程能访问数据的简便办法。在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么 在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。

互斥量:互斥量跟临界区很相似,只有拥有互斥对象的线程才具有访问资源的权限,由于互斥对象只有一个,因此就决定了任何情况下此共享资源都不会同时被多个线程所访问

常见是使用synchronized和互斥锁ReentrantLock,前者编译后生成monitorenter 和monitorexit指令,如果当前线程重复进行monitorenter那么计数加1,多少次monitorenter那么就是需要多少次monitorexit。计数为0表示放弃锁,如果获取锁前被另外一个线程执行了monitorenter 那么当前线程将一致被阻塞等待知道锁被释放。后者可以看我关于AQS的笔记

2.非阻塞同步

互斥同步属于一种悲观的并发策略(无论共享数据有无竞争,先上锁)都会导致用户态到内核态的切换。基于冲突检测的乐观并发策略——CAS(也有其他方式,但是提得最多的就是CAS了)比较并交换,CAS需要三个参数,第一个是共享数据的地址,第二个是预期的旧值,第三个是准备设置的新值,只有当前地址对应的值等于旧值的时候才会设置地址对应的值为新值,如果发现旧值不等于地址的值那么将操作失败。CAS的原子性由操作系统指令(如cmpxchg)来保证。Java中Unsafe类和原子类提供了CAS方法,可以看我关于原子类的笔记。

3.可重入代码

指在代码执行的任何时候都可以中断它,去执行另外一段代码(包括调用自己)控制权回来的时候程序不会出现任何错误,对结果也不会有任何影响。(可重入代码,一般都不依赖于全局变量,不依赖堆上的对象,状态都从参数传入,不调用其他不可重入的代码)有点类似无状态的servlet

4.ThreadLocal

把共享数据的可见限制在一个线程之内,那么无需同步也可以实现安全。关于ThreadLocal可以看我的相关源码学习

四丶锁优化

1.自旋锁 与自适应自旋

1.1自旋锁是什么,为什么需要自旋锁

互斥同步由于线程的挂起和回复线程的操作都需要从用户态和内核态的切换,导致jvm并发性能的降低。但是共享数据的锁定状态有时候只会持续一小段时间,为了加锁去挂起和恢复线程并不值得。自旋锁应运而生,不让线程立马挂起而是让线程执行自旋让线程等待锁的释放。

1.2自旋锁的缺点和自适应自旋

自旋是线程执行循环,虽然避免了线程的切换,但是自旋是需要占用处理器时间的,所以如果锁被占用的时间很短那么自旋锁能够有一个很好的效果,但是如果锁被霸占的时间比较长,那么线程一致进行自旋消耗cpu资源,将导致性能的浪费。因此自旋等待的时间需要有一定的限度,如果自旋的次数超过了限定的次数但是还是没有获取到锁,这时候需要挂起线程。在这个背景下,JDK6进行了自适应自旋,自适应自旋意味着自旋的时间不在固定了,而是由上一次在同一个锁上的自旋时间以及锁拥有者的状态来决定的。如果在同一个锁上面,自旋等待刚刚获取到了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间。如果一个锁,自旋很少成功获得锁,那么在后续对这个锁的争夺的时候将可能直接忽略自旋过程,避免处理器资源的浪费。有了自适应自旋,随着程序运行的时间以及性能监控的完善,虚拟机对锁的预测将越来越精准。

2.锁消除

指虚拟器即时编译器运行过程中,对于一段需要同步的代码,检测其根本不存在共享数据的竞争,那么将对竞争的锁进行消除

public synchronized String concat(String a,String b){
	return a+b;	
}
//string的连接操作总是产生一个新的字符串
//上面代码也许会被优化成栈中new一个 StringBuffer 进行append
//StringBuffer中的同步锁,锁的是StringBuffer对象,
//但是StringBuffer在栈中new出来的
//逃逸分析就发现动态作用域在concat内部那么就不需要线程同步,所以可以优化成StringBuiler

虚拟机发现这段代码中,在堆中的数据都不会逃逸出被其他线程访问到,那么就会视为栈上的数据对待,认为它们线程私有,那么加锁也就没有了必要。

3.锁粗化

随让我们通常推荐锁的粒度尽可能小,从而提高性能,但是如果对一个对象重复的进行加锁解锁,甚至是在for循环中加锁解锁,那么即使没有线程竞争,频繁进行互斥同步也是非常浪费性能,也许虚拟机会帮我们扩大锁的范围,比如扩大到for循环的外部,这样只需要进行一次加锁解锁了

4.锁升级

4.1Java对象头

以下是Java对象的内存布局

image

Java对象头存储的信息有:

  • mark word存储对象的hashCode,锁信息,分代年龄等
  • class pointer,存储当前对象类型数据的指针
  • array Lenth:如果是数组那么还有这部分来存储数组的长度

其中markword存储了许多和对象运行状态相关的数据,且这一部分涉及的非常的灵活,在不同的情况下可以表示不同的信息,下面是不同锁状态下32位虚拟机markword存储信息的结构

image

4.2 锁升级的概念

JDK6为了解决获取锁释放锁的性能消耗,引入了偏向锁和轻量级锁。锁的状态从低到高依次是无锁,偏向锁,轻量级锁,重量级锁

4.3偏向锁

hotspot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还常常是由同一个线程多次获取,那么能不能让锁记录这个线程,下次这个线程来直接获取锁即可,从而降低获得锁的代价。当一个线程范围同步块并且获取锁时,会在对象头和站在栈帧中记录存储锁偏向的线程ID,以后进入和退出同步块的时候都不需要进行CAS操作加锁和解锁。

4.3.1 偏向锁的获取

image

首先直接看对象头中是否存储了当前线程的id,如果存储了那么当前线程拿到锁,这就是偏向的含义,如果失败,那么再看下当前锁标志位是否是01,01表示无锁或者偏向锁,继续判断是否是偏向锁,如果是偏向锁那么需要查看是否存储了当前线程id,如果没有存储任何线程id那就CAS设置线程id。如果当前时无锁也是CAS设置线程Id

4.3.2 偏向锁的撤销

偏向锁使用一种等待竞争出现的时候才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等待全局安全点(这个时间点没有正在执行的字节码)首先需要暂停持有偏向锁的线程,然后检查持有偏向锁的线程是否存活,如果持有的线程不处于活动状态那么设置为无锁状态,如果任然存活,那么拥有偏向锁的栈才会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的markword要么偏向其他线程,要么恢复到无锁,要么标记对象不适合偏向锁。后续被标记不适合偏向锁那么将使用轻量级锁

image

4.4轻量级锁

4.4.1 轻量级锁的获取

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(lock record)并且将对象头中的mark word 赋值到锁记录lock record中,这种操作叫做Displaced mark word。然后线程产品使用CAS把对象头的mark word替换为指向锁记录的指针,如果成功那么表示当前线程获取到锁,如果失败,表示存在其他线程竞争锁,当前线程将通过自旋的方式获取锁。轻量级锁解决了竞争线程不多,并且锁很快就释放,这时候挂起唤醒线程不划算,通过自旋减少用户态到内核态的切换。

4.4.2轻量级锁解锁

解锁,使用CAS操作把Displaced Mark Word替换回到对象头,如果成功表示没有不存在竞争,如果失败表示当前锁存在竞争,那么锁会膨胀为重量级锁

image

4.5锁升级全流程

锁升级全过程 (1)