深入理解java虚拟机

3,221 阅读27分钟

前言

  1. JVM内存模型,JAVA内存模型,JAVA对象模型,这些名字相似的模型分别是什么?
  2. 常用的垃圾收集方法有哪些?垃圾收集器有哪些?各自有什么特点?
  3. JVM如何监控?调优?
  4. java编译后生成的class文件,内部存储格式是什么样的?
  5. 类加载有哪几个过程?什么是双亲委派模型?
  6. volatile和synchronized有什么区别?
  7. 乐观锁和悲观锁是什么?CAS原理?

以上问题在《深入理解java虚拟机》这本书里都有详尽的解答。

一. java各版本发展史

java各大版本特性

  • 96年发布1.0版本,代表技术:JVM,Applet,Awt
  • 97年发布1.1版本,代表技术:jar文件格式,jdbc,javabean,内部类,反射
  • 98年发布1.2版本,代表技术:jit,collections
  • 99年,HotSpot虚拟机发布,后成为jdk1.3及之后默认虚拟机
  • 00年发布1.3版本,代表技术:数学运算等类库,jndi服务
  • 02年发布1.4版本,走向成熟的版本。代表技术:正则,异常链,NIO,日志类,xml解析等
  • 04年发布1.5版本,代表技术:语法更易用,自动装箱,泛型,注解,美剧,foreach,可变长参数
  • 06年发布1.6版本,使用java6命名。代表技术:锁,同步,垃圾收集,累加值等算法优化。宣布开源
  • 09年发布java7,代表技术:G1收集器,升级类加载架构
  • 14年发布java8,长期支持的版本。代表技术:lambda表达式,stream,接口默认方法和静态方法,optional,base64,HashMap改进(红黑树)
  • 17年发布java9, 短期维护版本。代表技术:模块系统,jshell,接口的私有方法,HTTP2支持等。CMS垃圾回收器被废弃,默认垃圾回收器为G1(基于单线程标记扫描压缩算法)
  • 18年3月发布java10,短期维护版本。代表技术:var,G1改进(多线程并行GC)
  • 18年9月26日发布java11,长期支持的版本。代表技术:新一代垃圾回收器ZGC(实验阶段),JFR(监控、诊断),httpclient等

二. java内存区域划分

1. 程序计数器

  • 当前线程所执行的字节码行号指示器
  • 每个线程都有独立的程序计数器
  • 如果执行的是java方法,记录字节码指令地址。如果执行Native方法,则为空(Undefined)
  • 不会有OutOfMemoryError出现

2. 虚拟机栈

  • 线程私有, 生命周期与线程相同
  • 描述java方法执行的内存模型:每个方法都会创建一个栈帧用于存储局部变量,方法出口,操作数栈等信息。每个方法调用对应一个栈帧在虚拟机栈中入栈到出栈的过程
  • 存放基本数据类型(8种)和对象引用类型(地址的指针或者对象的句柄)
  • 请求栈深度大于虚拟机允许深度时,抛出StackOverflowError异常
  • 如果动态扩展仍无法申请足够的内存,抛出OutOfMemoryError异常

3. 本地方法栈

  • 作用和虚拟机栈一样
  • 区别为:本地方法栈服务虚拟机使用到的Native方法

4. 堆

  • 虚拟机管理的内存最大的一块
  • 被所有线程共享的区域
  • 所有对象的实例在此分片内存
  • 可细分为多个代

5. 方法区

  • 所有线程共享的区域
  • 存储类信息,常量,静态变量
  • 在HotSpot虚拟机上也称永久代
  • 垃圾收集行为很少出现在这个区域,因为可回收的内存很少

6. 运行时常量池

  • 是方法区的一部分
  • 用于存放编译器生成的各种字面量和符号引用

7. 直接内存

  • 不包括在JVM内存区域中,不受JVM参数影响
  • JVM使用缓冲区时,会在该区区域分配内存
  • 配置时注意给该区域预留空间,而不是把所有内存都分给JVM
  • -XX:MaxDirectMemorySize指定,不指定默认与堆最大值一样(-Xmx)

三. HotSpot虚拟机对象

1. 对象的创建

  • 收到new指令时,先检查是否能在常量池定位到类的符号引用

  • 有则表示类已经被加载,解析和初始化过。否则加载类。

  • 根据类大学分配堆内存。分配的方式有

    • 指针碰撞:内存规整(无压缩整理功能),仅移动指针。Serial,ParNew虚拟机
    • 空闲列表:内存不规整(有压缩整理功能),去空闲列表里找到一块足够的空间。CMS虚拟机

    分配过程的并发问题如何解决

    • 同步操作:CAS+重试
    • 内存按照线程预分配,称为本地线程分配缓冲(TLAB)。-XX:+/-UseTLAB参数决定
  • 对象初始化为零

  • 设置对象头信息:对象属于哪个类,对象hash码,GC分代年龄,是否启用偏向锁等等

  • 执行init方法做程序员需要的初始化

2. 对象的内存布局

对象在内存中的布局分为三个区域:对象头,实例数据,对齐填充

2.1 对象头

  • 对象头包括:对象自身的运行时数据,所属类类指针,数组长度(如果是数组对象)
  • 运行时数据区官方称为Mark Word
  • 运行时数据区是非固定的数据结构,根据标志位不同,存储内容不一样
  • 类型指针表明该对象属于哪个类实例
  • 如果是数组对象还包括数组的长度

2.2 实例数据

  • 对象真正存储的有效信息
  • 存储顺序受分片策略参数和源码定义顺序影响
  • 分配策略默认将长度长的分配在前面,字段相同的分配到一起

2.3 对齐填充

  • 不是必须存在的,仅占位符的作用
  • 对象大小必须为8字节整数倍,不足的通过对齐补全

3. 对象的访问定位

  • 使用句柄: 堆单独划分一块内存作为句柄池,reference存储句柄地址。对象移动时reference不需要修改。

  • 直接指针:reference直接存储对象地址。速度快。

四. 垃圾收集器与内存分配策略

1. 基本概念

1.1 收集的对象

堆,方法区中的内存区域

1.2 判定对象是否存活的方法

引用计数法
  • 给对象添加引用计数器
  • 实现简单
  • 无法解决对象直接相互循环引用的问题
  • 使用的代表:微软COM技术
可达性分析
  • 使用的代表:java,c#
  • 通过GC roots对象作为起始点,到该对象不可达时,证明对象不可用
  • GC roots对象包括以下几种
    • 虚拟机栈中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法中JNI引用的对象

1.3 引用的分类

  • 强引用:普遍存在new之后赋值操作,存在则永远不会被回收
  • 软引用:还有用,但并非必须但对象。内存溢出异常之前回收这些对象
  • 弱引用:强度比软引用更弱,只能存活到下一次垃圾回收之前
  • 虚引用:最弱到引用关系。无法通过虚引用得到对象。存在的目的是当垃圾回收时收到一个系统通知

1.4 方法区(永久代)的回收

  • 该区域的垃圾收集效率远远低于新生代(70%-95%)
  • 回收两类内容:废弃常量,无用的类
  • 判定是否是无用类的条件
    • 该类所有实例都被回收
    • 加载该类的classload被回收
    • 该类的java.lang.Class对象没有在任何地方被引用,无法通过反射访问
  • 满足以上条件的无用类可以被回收(不是必须)

2. 垃圾收集算法

2.1 标记-清除算法

  • 最基础的收集算法
  • 分为标记和清除两个阶段
  • 不足之处:
    • 效率问题
    • 产生大量不连续的内存碎片

2.2 复制算法

  • 将内存分为大小相等的两块,每次使用其中的一块
  • 一块用完时,将存活的对象复制到另一块
  • 现代虚拟机新生代都用该算法
  • 不足:
    • 内存利用率不高

HotSpot虚拟机将新生代内存分为较大的Eden区和两块较小的survivor空间。大小比例为8:1。

2.3 标记-整理算法

  • 对象存活率高时大量的复制会影响效率,老年代使用该算法
  • 标记过程与标记-清除算法一样
  • 后续步骤并不是清理对象,而是让所有存活的对象都向一段移动,清理边界以外的内存

2.4 分代收集算法

  • 根据对象存活周期不同,采用不同的收集算法
  • 新生代大量对象死亡,少量存活,采用复制算法
  • 老年代对象存活率高,采用标记-清理或者标记-收集算法

3. HotSpot的算法实现

3.1 枚举GC Roots

  • 可达性分析枚举GC Roots时 ,必须stop the world
  • 目前JVM使用准确式GC,停顿时并不需要一个个检查,而是从预先存放的地方直接取。(HotSpot保存在OopMap数据结构中)

3.2 安全点

  • 基于效率考虑,生成OopMap只会才特定的地方,称为安全点
  • 安全点的选定方法
    • 抢先式中断:现代JVM不采用
    • 主动式中断:线程轮询安全点标识,然后挂起

3.3 安全区域

  • 对于没有分配cpu的线程(sleep),安全点无法处理,由安全区域解决
  • 安全区域指一段代码中引用关系不会发生变化
  • 线程进入安全区域时,JVM发起GC就不用管这些线程,离开时需要检查GC是否完成,未完成就需要等待

3.4 垃圾收集器

serial收集器

  • 最基本,发展历史最悠久的收集器
  • jdk1.3.1之前新生代收集器唯一的选择
  • 单一线程收集器
  • GC时必须暂停其他所有的工作线程
  • 简单高效,对于单CPU的client模式来说是很好的选择
ParNew收集器

  • serial收集器的多线程版本
  • server模式的JVM首选的新生代收集器
  • 单CPU模式下,因线程切换开销,性能绝不比serial好
Parallel Scavenge
  • 采用复制算法的新生代收集器,支持多线程
  • 可以控制吞吐率
    • -XX:MaxGCPauseMillis 最大垃圾收集停顿时间,与吞吐量成反比
    • -XX:GCTimeRatio 吞吐量大小
  • 提供自适应调节测试
    • -XX:UseAdaptiveSizePolicy
  • 无法与CMS收集器配合工作
Serial Old

  • serial收集器的老年代版本
  • 使用标记-整理算法
  • 给client模式下虚拟机使用
Parallel Old

  • Parallel Scavenge老年代版本
  • 多线程,标记-整理算法
  • JDK1.6开始提供使用
Concurrent Marked Sweep(CMS)

  • 老年代收集器
  • 目标是尽可能减少GC停顿时间
  • 不会等到老年代空间快满了才回收(和用户线程并发,留内存给用户线程)。配置参数为-XX:CMSInitiazingOccupanyFraction
  • 使用标记-清除算法。整个过程分为四步:
    • 初始标记:STW,标记GC Roots能关联到的对象,速度很快
    • 并发标记:GC Roots Tracing过程。耗时。和用户线程一起执行
    • 重新标记:STW,标记并发标记过程中程序运行导致标记变化的对象,时间比初始标记长,远比并发标记短
    • 并发清除:耗时。和用户线程一起执行
  • 优点:
    • 并发收集
    • 低停顿
  • 缺点:
    • 占用正在执行的用户程序的cpu资源
    • 无法处理浮动垃圾(并发清理过程中产生的新垃圾无法当次处理掉)
    • 内存碎片问题
Garbage First( G1)

  • 最前沿的垃圾收集器
  • jdk1.7版本发布,替换jdk1.5的CMS
  • 堆内存布局与其他收集器不一样,新生代老年代不再是物理隔离的,而是Region集合
  • 根据各个region垃圾回收的价值,加入优先级队列。保证每次GC能在有限时间内得到最高的收集率
  • 通过Remember set保证跨region的区域不需要全堆扫描
  • 运行步骤
    • 初始标记:STW,时很短。标记GC Roots关联的对象
    • 并发标记:可达性分析,耗时长。可与用户线程并发执行
    • 最终标记:STW,修正并发标记阶段用户线程运行导致标记变化的部分
    • 筛选回收:排序各个region的回收价值,制定回收计划
  • 优点
    • 并行与并发
    • 分代收集
    • 空间整理:不会产生内存碎片
    • 可预测的停顿:几乎是实时的垃圾收集

4. 内存分配与回收策略

4.1 对象优先在Eden区分配

  • eden区空间不足时,JVM发起一次Minor GC

    Minor GC vs Full GC

    • minor gc:新生代gc,频繁发生,速度快
    • major gc/full gc:老年代gc,速度慢

4.2 大对象直接进入老年代

  • 典型代表是:很长的字符串或数组
  • 大对象对JVM不友好,导致频繁发生GC

4.3 长期存活的对象将进入老年代

  • 对象经过Eden的第一次minor gc仍然存活,被移动到survivor,年龄+1
  • 对象在survivor每经过一次minor gc,年龄+1
  • 年龄加到一定程度(默认15),将进入老年代。参数:-XX:MaxTenuringThreshold

4.4 动态年龄判断

  • 并不是永远要求年龄达到设定的值才进入老年代
  • 当survivor空间中相同年龄所有对象大小大于空间的一半,大于等于该年龄的对象就直接进入老年代

4.5 空间分配担保

  • minor gc执行之前会检查老年代最大可用的连续空间是否大于新生代所有对象总空间
  • 不成立则判断是否大于历次晋升到老年代对象的平均大小
  • 各种条件不满足则进行full gc

5. jvm性能监控

5.1 jdk的命令行工具

  • jps:查看运行的虚拟机进程
  • jstat:统计信息监控工具。参数有:
    • -class:类装载信息
    • -gc:监视堆,包括eden、survivor、老年代、持久代空间,gc时间等
    • -gccapacity: 同-gc,不过主要关注各区域最大,最小空间
    • -gcutil:同-gc,不过主要关注占用百分比
    • -gccause:同-gcutil,不过会输出导致上一次GC的原因
    • -gcnew:监视新生代
    • -gcnewcapacity:同-gcnew,关注最大,最小空间
    • -gcold:监视老年代
    • -gcoldcapacity:同-gcold,关注最大最小空间
    • -gcpermcapacity:永久代最大,最小空间
    • -compiler:JIT编译信息
    • printcompilation:JIT编译的方法
  • jinfo:java配置信息工具。实时查看和调整虚拟机各项参数
  • jmap:内存映像工具。参数有:
    • -dump:生成java堆存储快照
    • -finalizerinfo:等待执行finalize方法的对象
    • -heap:显示java堆详细信息:回收期,参数配置,分代状况
    • -histo:堆对象统计信息:类,实例数量,总容量
    • -permstat:永久代内存状态
    • -F:强制生成快照
  • jhat:分析dump文件。一般不用。用第三方的VisualVM,eclipse,Memory Analyzer, heap analyzer等
  • jstack:java堆栈追踪工具,用于定位长时间停顿的线程当前栈情况

5.2 jdk的可视化工具

  • jconsole
  • VisualVM

五. 类文件结构

1. class类的文件结构概述

  • class文件是一组以8位字节为基础单位的二进制流
  • 各个数据项严格紧凑排步
  • 8位字节以上的数据,按高位在前的顺序分割为多个8位字节存储
  • 采用类似与c语言结构体的伪结构存储
  • 伪结构有两种数据类型:
    • 无符号:基本数据类型,包括u1,u2,u4,u8,数字代表字节数
    • 表(复合结构):以_info结尾
  • 下表的顺序,数量,存储的字节都是严格限定的

2. magic

  • 头四个字节
  • 确定该class文件是否能被jvm接受,用于身份识别
  • 值为:0xCAFFEBABE

3. 版本号

  • minor-version:5-6字节
  • major-version:7-8字节

4. 常量池

  • class文件中的资源仓库,数量不固定,在入口的地方由一个u2类型的数据指定常量池的容量
  • 常量池第0项是空出来的,目的在于后面某些常量池索引值不引用任何一个常量池,就把索引值置为0
  • 主要存放两大类常
    • 字面量: 文本字符串,final常量等
    • 符号引用:类和接口的全名,字段的名称和描述符,方法的名称和描述符
  • 常量池中每一项常量都是一个表,每种表开始的第一位是u1类型的标识位,代表当前的常量属于哪种常量类型
  • 常量池的项目类型如下:

5. 访问标识

  • 常量池之后的两个字节
  • 标识该类为普通类、接口、枚举、还是注解。public还是private等

6. 类索引、父类索引与接口索引集合

  • 类索引、父类索引是u2类型数据,接口索引是-组u2类型数据集合
  • 用于确定这个类的继承关系

7. 字段表集合

  • 描述接口或类中声明的变量,不包括局部变量
  • 包含的信息有:作用域,static修饰符,final修饰符,数据类型,volatile,transient,名称

8. 方法表集合

  • 类似于字段表集合
  • 包括访问标识、名称索引、描述符索引、属性表集合

9. 属性表集合

  • 用于描述某些场景的专有信息
  • 没有顺序,长度和内容的限制,只要不和已有的属性名重复

六. 字节码指令

1. 概述

  • jvm的指令=操作码(1字节)+操作数
  • 大多数指令只有操作码,没有操作数
  • 操作码的数量不会超过256(2^8)
  • 由于操作数没有对齐,所以超过一个字节的数据,运行时需要重建数据。优点是省略很多填充和间隔,缺点是性能有损失

2. 加载和存储指令

  • 用于将数据在栈帧中的局部变量表和操作数栈间来回传输
  • 将局部变量加载到操作栈:iload, iload_,lload,fload,dload,aload(reference load)
  • 从操作数栈存储到局部变量表:istore, istore_,lstore,fstore,dstore,astore
  • 常量加载到操作数栈:bipush,sipush

3. 运算指令

  • 对操作数栈上对两个值做运算,再存回去
  • byte,short,char,boolean类型都使用int类型指令替代
  • 加法指令:iadd,ladd,fadd,dadd
  • 减法指令:isub,lsub,...
  • 乘法指令:imul,...
  • 除法指令:idev,...
  • ...

4. 类型转化指令

  • 宽化类型转化:无需显示都转化指令
    • int转long,float,double
    • long转float,double
    • float转double
  • 窄化类型转化:必须显示使用转化指令
    • 包括:i2b,i2c,i2s,l2i...

5. 对象创建与转化指令

  • 创建类实例的指令:new
  • 创建数组的指令:newarray,anewarray,multianewarray

6. 操作数栈管理指令

  • 操作数栈顶一个或两个数据出栈:pop,pop2
  • 复制栈顶数据再压栈:dup,dup2
  • 交换栈顶俩元素:swap

7. 控制转移指令

  • 条件分支:ifeq,iflt,...
  • 复合条件分支:tableswitch,lookupswitch
  • 无条件分支:goto,ret

8. 方法调用与返回指令

  • 调用对象的实例方法:invokevirtual
  • 调用接口方法:invokeinterface
  • 调用特殊方法:初始化,父类方法等:invokespecial
  • 调用静态方法:invokestatic

9. 异常处理指令

  • athrow

10. 同步指令

  • synchronized对应的指令:monitorenter,monitorexit

七. 类加载机制和类加载器

1. 类加载机制

1.1 概述

类从被加载到内存中开始,到卸载出内存为止,整个生命周期包括

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

类加载时机没有强制规定,但是初始化阶段,有且只有以下情况下必须对类进行初始化:

  • 遇到new,getstatic,putstatic,invokestatic这四条指令码时。对应java代码为:new,设置静态字段,调用静态方法
  • java.lang.reflect包对类进行反射时
  • 初始化一个类时,如果父类还没有被初始化时初始化父类(接口没有这个要求)
  • 虚拟机启动时,主类(main)的初始化
  • java.lang.invoke.MethodHandle特定的解析结果时

1.2 加载

  • 通过类名获取二进制字节流:来源可以是jar,war等。也可以是网络,代理等。
  • 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成java.lang.Class对象,作为方法区该类的访问入口

1.3 验证

  • 为了确保class文件的字节流中包含的信息符合虚拟机要求
  • 保护虚拟机自身的一项重要工作,防止被恶意攻击
  • 验证内容具体包括:
    • 文件格式验证:魔数是否是0xCAFEBABE, 主次版本号是否被当前jvm允许,常量池类型是否正确等等
    • 元数据验证:是否有父类,是否继承了final类,非抽象类是否实现了所有方法等等
    • 字节码验证:最复杂。验证数据放入和取出栈是同一类型,指令不会跳转到方法体以外等
    • 符号引用验证:符号引用中通过名称能否找到对应的类等

1.4 准备

  • 为类变量(static类型)分配内存并设置类变量初始值的阶段
  • 初始值一般指0,而不是代码初始化的值。除非指定为final的static变量
  • 以下是默认的初始值

1.5 解析

  • 将常量池内的符号引用替换为直接引用
    • 符号引用:以一组符号来描述引用的目标,与JVM内存布局无关
    • 直接引用:与JVM内存布局有关,直接指向目标的指针或者偏移地址
  • 解析包括:
    • 类或接口的解析
    • 字段解析
    • 类方法解析
    • 接口方法解析

1.6 初始化

  • 真正执行类中定义的java代码
  • 执行类构造器方法的过程
  • client方法由所有static变量和static代码合并得到
  • 该方法执行是多线程安全的

2. 类加载器

2.1 类加载器

  • 存在与jvm外部,实现类的加载操作
  • 任意一个类,都由类加载器和类本身共同确定唯一性
  • 比较类是否相等,只有在同一个类加载器加载的前提下才有意义

2.2 类加载器的分类

  • 启动类加载器:jvm识别,加载lib目录下特定的jar
  • 扩展类加载器:加载lib/ext目录的jar
  • 应用程序加载器:加载用户类,ClassLoader实现,用户可直接使用
  • 自定义加载器:自定义的加载器 关系如下:

2.2 双亲委派模型

  • 各个类加载器之间如图的层次关系称为双亲委派模型
  • 要求除了顶层的启动类加载器外,其余加载器都必须有父加载器(通过组合而不是继承关系来实现)
  • 工作过程:类加载器收到加载请求,它首先不会自己尝试去加载类,而是把请求委托给父类去加载。所以所有的请求都会先被启动类加载器加载。当父类加载不了时,才由子类加载
  • 作用:保证了基础类在各种类加载器中都是同一个类,否则java类型体系将一片混乱
  • 应用:tomcat不同服务要隔离,公共部分要重用。各式各样的类需要加载,都是通过双亲委派模型实现的

八. jvm字节码执行机制

1. 运行时栈结构

1.1 概述

  • 支持方法调用和执行的数据结构
  • 处于jvm内存模型中的java虚拟机栈区域
  • 存储了局部变量表,操作数栈,动态连接,方法返回地址等信息
  • 每个方法的调用都对应虚拟机栈的入栈到出栈过程

1.2 局部变量表

  • 存放方法参数和局部变量
  • 局部变量不像类成员变量会被默认初始化
  • 局部变量表第0号索引默认存放this

1.3 操作数栈

  • 方法执行过程中,各种字节码指令往操作数栈入栈和出栈

1.4 动态连接

  • 每个栈帧中都有一个该栈所属方法的引用,用于动态连接

1.5 方法返回地址

2. 方法调用

2.1 解析

2.2 分派

  • 静态分派
  • 动态分派
  • 单分派和多分派
  • 虚拟机动态分派实现

3. 方法的执行

  • 解释执行
  • 编译执行

九. java内存模型

1. 效率与一致性

  • 高速缓存解决了处理器与内存的速度矛盾
  • 但是引入了缓存一致性的问题
  • 处理器的优化和编译器的指令重拍也会导致缓存不一致

2. java内存模型

2.1 概述

  • 特性:围绕着在并发过程中如何处理原子性,可见性,有序性这三个特征建立的
  • 作用:JVM定义JMM来屏蔽各种硬件和操作系统的内存访问差异,达到在各种平台有一致性的内存访问效果
  • 目标:定义变量的访问规则,包括变量存储到内存和从内存取出变量值。这里的变量指会被共享的实例字段,类字段。不包括不被共享的局部变量
  • 规定:所有变量都存储在主存中,每个线程都有自己的工作内存,工作内存保存了主内存变量的副本。线程对变量的操作必须在工作内存中,不能在主存中。不同线程之间也不能互相访问,线程间变量传递需要经过主存。

2.2 主存与工作内存交互协议

协议包括的原子操作:

一个变量如何从主存拷贝到工作内存,如何从工作内存同步回主存,java内存模型定义了8中操作来完成,每种操作都是原子性的:

  • lock:作用与主内存变量,变量被一个线程独占
  • unlock:作用与主内存变量,解锁,可被其他线程锁定
  • read:作用与主内存变量,把一个变量值从主内存传输到工作内存,以便load
  • load:作用与工作内存变量,把read操作从主存得到的值放入工作内存变量副本
  • use:作用与工作内存变量,把工作内存变量值传递给执行引擎
  • assign:作用与工作内存变量,把执行引擎的值赋给工作内存变量
  • store:作用与工作内存变量,工作内存变量传给主内存,以便write操作
  • write:作用与主内存变量,store得到的值放入主存变量
协议规则
  • 主内存复制到工作内存:必须顺序执行read和load
  • 工作内存同步回主存:必须顺序执行store和write
  • 没有要求连续执行,即中间可以插入其他操作
  • 不允许read和load、store和write操作之一单独出现
  • 使用use,store之前必须执行assign和load操作。即变量只能在主内存中诞生
  • 一个变量同一个时刻只能有一个线程lock,但同一个线程可以多次lock,然后执行相同次数unlock才能被释放
  • 执行lock时,会清空工作内存中变量的值,使用时需要重新load和assign
  • 没有被lock,就不能执行unlock
  • 执行unlock时,必须先把变量值同步回主存

2.3 java内存模型定义Volatile的特殊规则

变量定义为volatile之后,将具备两种特性:

  • 可见性:保证此变量对所有线程的可见性。即一个线程修改了变量,另一个线程立马可以得知。而普通变量需要经过主存传递才能完成。

    可见性并不能保证并发安全,因为操作可能不是原子的。

  • 禁止指令重排序优化。保证变量赋值顺序与代码执行顺序一致。

2.4 java内存模型对long,double类型的特殊规则

  • 对于64位数据类型,JVM运行将没有被volatile修饰的变量读写划分为两个32位操作来进行。即long和double的非原子性协定
  • 不过JVM把64位数据的操作实现为原子性的。所以不需要专门声明为volatile

2.5 原子性,可见性,有序性

  • 原子性:java内存模型的规则保证了基本数据类型的访问是原子性的。更大范围的原子性可通过synchronized和lock来保证
  • 可见性:java内存模型通过在变量修改后将新值同步回主内存,读取变量前从主内存刷新新值这种通过主内存传递的方式实现可见性。volatile保证了多线程操作时变量的可见性,普通变量则不能保证。synchronized和final也能实现可见性
  • 有序性:本线程观察,所有操作都是有序的。在一个线程中观察另一个线程,所有操作都是无序的。volatile和synchronized保证线程操作的有序性。

十. 线程安全与锁

1. 线程状态

任意一个时间点,一个线程有且只能有一种状态

  • 新建(New):创建后尚未启动的状态
  • 运行(Runable):正在运行或等待cpu为它分片执行时间
  • 无限期等待(Waiting):不会被分配cpu时间,要等待被其他线程显示唤醒。以下方法会出现该状态
    • 没有timeout的Ojbect.wait方法
    • 没有timeout的Thread.join方法
    • LockSupport.park方法
  • 限期等待(Timed Waiting):不会被分配cpu时间,不过不需要被唤醒,一定时间后系统自动唤醒。
    • Thread.sleep()
    • 有timeout的Ojbect.wait方法
    • 有timeout的Thread.join方法
    • LockSupport.parkNanos方法
    • LockSupport.parkUntil方法
  • 阻塞(Blocked):线程进入同步区域,等待获取排他锁的状态
  • 结束(Treminated):结束执行

2. 线程安全的实现方法

2.1 互斥同步(阻塞同步,悲观锁)

  • 同步:多线程并发访问数据时,保证统一时刻只被一个线程使用
  • 互斥:实现同步的手段,包括:临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)
  • synchronized:最基本的互斥同步手段。编译后会在同步代码前后添加monitorenter和monitorexit指令。执行monitorenter时,要尝试获取对象的锁,已经拥有就吧锁计数加1(可重入),否则阻塞知道锁被释放。阻塞会调用内核,消耗大量切换时间。JVM优化机制会在前面加一段自旋等待。
  • ReentrantLock:api层面的互斥锁,多了一些高级特性。如等待可中断,可实现公平锁等等。默认非公平。
  • 互斥同步的缺点:线程阻塞和唤醒的性能问题

2.2 非阻塞同步(乐观锁)

  • 需要硬件来保证操作和冲突检测的原子性
  • CAS:compare-and-swap,典型的非阻塞同步。包括3个操作数
    • 内存位置:V
    • 旧的预期值:A
    • 新值:B
  • CAS只有当V符合A时,采用B更新V,否则不更新。无论是否更新,都返回V的旧值,该过程是原子操作
  • CAS的缺点:ABA问题:旧值被改过再改回来无法感知到

ThreadLocal

  • 将一些不需要被多线程访问的变量由单个线程去独享

3. 锁优化

JDK1.6版本针对高并发实现了各种锁优化技术

3.1 自旋锁和自适应自旋

  • 自旋锁:线程等待的时候,并不是挂起,而是执行一个忙循环。
  • 自适应自旋锁:根据上一次自旋时间及拥有者状态自动调整自旋时间

3.2 锁消除

  • 编译器运行时,自动检测出那些不可能存在共享数据竞争的锁,然后进行消除

3.3 锁粗化

  • 将一串零碎的加锁同步操作,扩大范围到整个序列以外

3.4 轻量级锁

  • 相对于使用操作系统互斥量来实现的传统锁而言的。
  • 使用CAS操作避免了使用互斥量的开销

3.5 偏向锁

  • 消除数据在无竞争情况下的同步,进一步提高性能
  • 无竞争的情况下把整个同步都消除掉,CAS都不做