JVM初读
前记:
- 参考书目《深入理解Java虚拟机》(第3版)
- 大多数情况是基于HotSpot虚拟机来说的
第一次阅读(大二暑假期间:2021.7.21 ~ 2021.8.11):
- 阅读了基础知识,即第一、二、三、六、七、十二章节。其中,有些知识还有些吃力,因为没有具备或先阅读相关知识(跳着读了。。。),尤其是字节码的分析部分,指令分析部分。
- 剩下部分有需要有时间再进行阅读
一、走近Java
编译器 与 解释器
- 基本概念
- 编译:把源代码转换(翻译)成低级语言的过程
- 解释:把高级编程语言一行一行直接 转译 运行 的过程
- 编译器与解释器
- 编译器是把源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,速度很快
- 解释器则是只在执行程序时,才一条一条的解释成机器语言给计算机来执行,所以运行速度是不如编译后的程序运行的快的
- 编译程序与解释程序
- 编译程序是独立的程序
- 解释程序需要解释器来运行。跨平台性更强(运行时解释)
- Java通过编译生成class字节码,而解释器的任务是执行字节码文件。也就是说,Java真正执行的是字节码。
二、Java内存与溢出
1概述
和C/C++比较,Java的内存管理完全交给JVM管理。学习JVM的内存结构是为了在出现内存问题时的分析方便,否则一旦出现内存管理问题,则无从下手。
2运行时数据区域
一定要手写出来的图
-
程序计数器
- 是当前线程所执行的字节码的行数
- 每个线程都独立的,各个线程之间计数器互不影响
- 如果执行的是Native方法,则计数器为空(Undefined)
-
Java虚拟机栈
- 线程私有的,生命周期与线程相同
- 会创建栈帧,存储局部变量表、操作数栈、动态链接、方法出口等信息
- 局部变量表存放着基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)。存储空间用局部变量槽来来表示。
- 每一个方法被调用直到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
- 栈深度溢出或栈扩展失败会抛出 StackOverflowError 和 OutOfMemoryError 异常
-
本地方法栈
- 作用与虚拟机方法栈类似,不过是为虚拟机使用到的本地方法服务的
- HotSpot把本地方法栈和虚拟机栈合在了一起
-
Java堆
- 虚拟机管理的内存中最大的一块,被所有线程所共享
- 此内存区域的唯一目的是:存放 对象实例(数组其实也可以看成一个对象)
- Java堆是垃圾收集器管理的内存区域。由于GC的分代收集理论,故会把Heap分成新生代、老年代和永久代。
- 堆在物理上是不连续的(如果文件指针块的方式存储),在逻辑上是连续的
-
方法区
- 被所有线程共享,在物理上是不存在的,是堆的一个逻辑部分
- 存储 被虚拟机加载的类型信息(如类名、修饰符、字段描述等)、常量、静态变量、即时编译器编译后的代码缓存等数据
运行时常量池
- 是方法区的一部分,存放常量池表(存放编译期生成的字面量 与 符号引用)
- JDK7之后的字符串常量池在堆中
-
直接内存
- 不是虚拟机运行时数据区的一部分
- 可以使用Native函数库直接分配堆外内存
3HotSpot对象探秘
-
对象创建过程
- 检查信息。如是否能找到类的符号引用,类是否已经被加载过。
- 分配内存。所有的字段都为默认值。
- 必要设置。设置对象头(如是哪个类的实例,如何找到类的元数据信息,对象的哈希码,GC分代年龄信息等)等
- 从虚拟机角度看,一个新对象创建成功。但对于Java程序,还没有执行构造函数,即()方法还没有执行。new指令之后一般会执行()方法。这样才算是真正完全构造出对象
-
对象内存布局
-
对象头(Header)。分为两个部分:
- 存储对象自身的运行时数据。如哈希码,GC分代年龄、锁状态等
- 类型指针,即指向它的类型元数据的指针。通过该指针确定该对象是哪个类的实例
元数据信息中可以确定对象的大小和数组的长度。
-
实例数据(Instance Data)。
-
对齐填充(Padding)。
-
-
对象的访问定位(通过引用到到对象体)
- 句柄访问。好处是稳定句柄地址reference不用改变,坏处是间接访问了两次内存
- 直接指针访问。好处是速度快。(HotSpot的使用方法)
4实战OOM异常
虚拟机内存相关参数
-Xms 设置堆最小值
-Xmx 设置堆最大值 (两者同时设置可避免堆的自动扩展)
-Xss 设置栈的内存容量
-XX:+PrintGCDetails 查看内存区域信息
内存溢出的异常测试,是基于知道每个内存区域存放什么数据类型而进行的。
- Java堆溢出:无限new对象,并保留引用路径
- 虚拟机和本地方法栈:无限递归调用方法本身。
- 方法区:字符串intern() 和 动态类 的测试
- 直接内存:通过unsafe分配本机内存
5杂项
- JDK6及以前,字符串常量池在方法区中
- JDK7及以后,字符串常量池在堆中
- JDK8之后,彻底去除永久代
三、垃圾收集器与内存分配策略
1概述
- 垃圾回收 即 内存回收
- 垃圾收集(Garbage Collection)这项技术比Java语言悠久
- 垃圾收集需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
- 对于Java运行时内存区域,程序计数器、虚拟机栈、本地方法栈不用进行GC,他们会随着线程的创建与死亡而分配和回收。
- 内存的分配与回收,在大部分情况下都是特指 堆 和 方法区 而言。
2对象已死?
对象死去:不再被任何途径使用的对象
引用计数算法 Reference Counting
- 在对象中添加一个引用计数器:
- 有一个地方引用它,计数器值加1
- 引用失效,计数器值减1
- 任何时刻计数器为0时,对象就是不可能再被使用的
- 问题:很难解决对象之间相互循环引用的问题,要配合大量额外处理才能保证正确工作
可达性分析算法 Reachablility Analysis
- 用来判定对象是否存活(对象死去:不再被任何途径使用的对象)
- 基本原理:通过一系统称为“GC Roots”的对象作为起点集,根据引用关系向下搜索(搜索走过的路径称为 引用链)。如果一个对象到GCRoots没有任何引用链,则证明此对象不可能再被使用
- Java中,有固定的几种对象作为GCRoots:如栈中本地变量表中对象,静态属性引用的对象等(P70)
- 临时性GCRoots:如果要进行局部的GC,要让其他内存区域的对象临时加入到GCRoots集合中,才能进行正确的可达性分析
Java引用分类
传统定义:如果Reference 类型中的数据存储的数值代表的是另外一块内存的起始地址,就称该Reference数据是代表某块内存、某个对象的引用。
- 强引用:传统引用定义,绝对不会被回收。
- 软引用:内存溢出异常前,进行回收。用于标记非必须对象。SoftReference实现
- 弱引用:只要被GC发现,就会被回收。用于标记非必须对象。WeakReference实现
- 虚引用:唯一目的是在对象回收进进行系统通知。PhantomReference实现。
对象死亡过程
- 最多经历过两次标记过程,才会被真正宣告死亡(可以被GC回收)
- 第一次标记:被可达性算法判定为“死亡”对象
- 第二次标记:对象没有覆盖finalize()方法 或 finalize()方法被虚拟机调用过(即不会执行两次finalize方法)
- finalize()方法是对象逃脱死亡的最后一次机会。如果对象被判定有必要执行finalize()方法,则会加入到F-Queue队列中去,进行第二次标记。
- finalize()方法中,如果把自己的this引用,链接到另外一个对象中去,那么可以逃脱死亡命运。
回收方法区
- 《Java虚拟机规范》中不要求虚拟机在方法区中实现GC
- 方法区收集的主要对象:废弃的常量 和 不再使用的类型
- HotSpot 提供参数对方法区进行回收,即HotSpot有方法区的GC功能。
3垃圾回收算法
垃圾收集算法
- 垃圾收集算法分为两类:(从如何判定对象消亡的角度)
- 引用计数式垃圾收集(直接垃圾收集)(注意和前面的引用数算法区分,看英文名称就知道,一个有垃圾回收行为,一个只是判定对象死亡算法)
- 追踪式垃圾收集(间接垃圾收集)
- 以下算法都是追踪式垃圾算法
分代收集理论
-
三个分代假说
- 弱份代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
-
Java堆划分:新生代(Young Generation)和老年代(Old Generation)
-
对象年龄:对象熬过垃圾收集过程的次数
-
前两个分代假说奠定了一致的设计原则:收集器应该将Java堆划分出不同区域,然后将回收对象依据其年龄分配到不同的区域
-
后一个理论是由于:
- 对象不是孤立的,对象之间可能存在引用。
- 老年代可能有新生代的引用,所以为了清除新生代而不得不扫描整个老年代以生成完整GCRoots。
- 有了第三条假说,可以把老年代划分为小块,其中有一个小块专门存放有跨代引用的对象
-
垃圾收集器分类:
部分收集(Partial GC):不是完整收集整个堆的垃圾收集
- 新生代收集(Minor GC / Young GC):对新生代收集(Eden存活对象收集到幸运区或担保到老年区,幸存区看年龄和总容量)
- 老年代收集(Major GC / Old GC):对老年代垃圾收集
- 混合收集(Mixed GC):对整个新生代 和 部分老年代垃圾收集
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
垃圾算法
- 标记-清除算法Mark-Sweep
- 动作:首先标记出需要回收的对象,然后统一回收被标记的对象。(也可以标记不需要回收的对象)
- 缺点:效率不稳定,大量对象的回收太多的标记清除过程;内存容易产生碎片
- 标记-复制算法
- 动作:把内存划分为两个等大的区域,一块内存用完,用将活着的对象复制到另一个空区域上,然后清除满的区域
- 优点:不用考虑内存碎片;缺点:缩小了可用内存
- 引发的新生代的划分:Eden空间 + 两个Survivor空间。
- 动作:当发生垃圾收集时,会把Eden和其中一个Survivor空间的存活对象全部复制到另一个Survivor空间(理论:新生代中的对象有98%熬不过第一轮收集)
- 分配担保:如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,那么这些对象就通过分配担保机制直接进入老年代
- 标记-整理算法Mark-Compact (翻译成标记压缩更好)
- 基本原理和标记清除一样,唯一区别是:标记整理算法是移动式算法,可以去除内存碎片
- 缺点:移动存活对象会浪费时间
- 标记整理 和 标记清除 都会停顿用户线程来进行标记清理
- 结合法:平时多数时间采用标记-清除算法,容忍一定空间碎片,容忍不了时再用标记-整理算法。
8实战:内存分配与回收策略
Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:
- 自动分配内存
- 自动回收内存
-Xmn10M 新生代分配内存为10M
-SurvivorRatio=8 规定Eden:一个Survivor区大小为8:1
-XX:PretenureSizeThreshold=10M 大于10M的对象直接在老年区分配
-XX:MaxTenuringThreshold=1 对象年龄大于1才能晋升老年区
-XX:+PrintGCDetails 当发生GC时 和 进程退出时 进行日志打印
-XX:+PrintTenuringDistribution 发生GC时,打印老年区日志
注意:分为三个区是基本 标记-复制算法 而讨论的。
- 对象优先在Eden分配
- 大多数情况下,对象在新生代Eden区中分配
- 当Eden区没有足够空间进行分配时,会进行一次Minor GC
- 大对象直接进入老年代
- 避免大对象来回在Eden区和两个幸存区中来回复制,浪费效率
- 长期存活的对象将进入老年代
- 通过对象头中的对象年龄计数器来判断
- 对象在幸存区中每熬过一次Minor GC,年龄加1
- 动态对象年龄判定
- HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代
- 在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
- 空间分配担保
- 把Survivor无法容纳的对象直接送入老年代
六、类文件结构
1概述
2无关性的基石
字节码(Byte Code)是无关性的基石。
无关性体现在两个方面
- 语言无关性:任何语言都可以编译成中立的字节码格式
- 平台无关性:与操作系统和机器指令集无关的、平台中立的
3Class类文件结构
- Java技术保持良好向后兼容的原因:在原有的结构基础上新增内容、扩充功能,并未对已定义的内容做出修改
- Class文件格式伪结构只有两种数据类型:“无符号数” 和 “表”
- Class文件是一组以字节为基础单位的二进制流,采用的是大端模式
4字节码指令简介
- Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中
- 指令在class文件中的位置:方法表中属性集合中的Code属性中的code段中
- 关于Java指令集中所支持的数据类型:
- 大部分指令都没有支持byte, char, short的,没有任何指令支持boolean类型
- 编译器在编译期将byte和short类型数据带符号扩展到相应的int类型。将boolean和char零位扩展为相应的int类型
- 所以JVM对int类型的指令是最丰富的
- 类型转换指令
- 宽化类型转换(即小范围到大范围的安全转换):无须显式转换指令
- 窄化类型转换(即大到小):有具体转换指令
- 窄型数据转换细节:
- 将int 或 long 转换为小整数时:仅仅将高位的丢弃,可能会产生正负号的转变
- 浮点值转为整数:浮点是NaN, 转换结果是int 或long 类型的0 ;无穷大,则转化整数最大值;不是无穷大,则向零传入取整
- double 转 float :double太大、大小,将转为float的0、无穷;如果是NaN,则转为float的NaN
七、虚拟机类加载机制
1概述
- 类加载机制:JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这个过程称为JVM的类加载机制
- Java的类型加载、连接和初始化都是在程序运行期间完成的
2类加载的时机
基本周期
- 加载、验证、准备、初始化和卸载 五个阶段顺序是固定的,会在一个阶段执行的过程中调用、激活另一个阶段。但解析不是固定的,可以在初始化之后进行(适应动态绑定)
- 大致分为:加载,连接,初始化。
初始化情形
以下都称为 主动引用:
- new关键字
- 读取、设置静态字段
- 调用静态方法
- 调用java.lang.reflect对类型进行反射调用
- 初始化子类时,要先初始化父类
- 包含main()方法的类,会先初始化
- P264 最后两个,不明白。跳过。
被动引用:
不会触发初始化
- 通过子类引用父类的静态变量,不会触发子类初始化
- 通过new数组,不会触发具体类型的初始化。但会触发对应类型的数组对象的初始化
- 调用类常量(不是类变量)时,不会触发对应类的初始化。原因是,编译后会将被引用的类常量,放入调用类本身的常量池。调用类和被调用类之间在编译之后就没有任何关系了
3类加载的过程
加载
和类加载区分:加载是类加载的一个过程
- 加载需要做的三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在堆中生成一个代表该类的java.lang.Class对象。作为访问方法区中的类型数据的外部接口
- 通过二进制流的方式加载类:
- 通过ZIP包读取,是JAR、WAR格式的基本
- 从网络中获取
- 运行时计算生成----动态代理技术,生成“*$Proxy"的二进制字节流 (动态代理底层原理)
- 加载可以使用内置启动类加载器,也可以用用户自定义的类加载器去控制字节流的获取方式。(重写一个类加载器的findClass()和loadClass()方法)
- 数组类本身不通过类加载器创建,由JVM直接在内存中动态构造出来
验证
是连接阶段的第一步,确保Class文件字节流符合约束规范,并保证不会危害到JVM。
四大验证:
- 文件格式验证
- 验证字节流是否符合Class文件格式,即是否符合Class类文件结构
- 发生在Class到方法区的过程中,只有通过验证才能进入方法区
- 所以后面三个阶段是基于方法区在存储结构上进行的。
- 元数据验证:对类的元数据信息进行验证。(如父类,字段是否与父类final冲突等,抽象类方法实现等)
- 字节码验证(不怎么理解):对类的方法体进行校验分析
- 符号引用验证
- 发生在JVM把符号引用转换为直接引用的时候
- 主要目的是确保解析行为能正常执行
- 常见的java.lang.NoSuchMethodError 异常发生在这个阶段
准备
准备阶段是正式为类中定义的静态变量分配内存并设置默认值的阶段。
- 类变量分配内存在方法区内,且会被设置成默认值
- 类变量的赋值动作会在()方法中。也就是说,类变量赋的值存储在class文件的方法表的Code属性中。
- 如果是常量,会根据class文件的字段表的ConstantValue属性进行初始化
- 注意:实例变量的内存会随着对象实例化后,跟着对象分配内存在堆中
解析
解析:是JVM将常量池内的符号引用替换为直接引用的过程。并进行权限检查。
(和验证中的引用验证区别:验证是看能不能找到,解析是找到符号引用后,解析成直接引用,即解析是产生真正动作的步骤)(解析和引用验证是紧密结合的:先验证 => 找到符号 => 解析 => 直接引用 )
- 两种引用
- 符号引用:用一组符号描述所引用的目标,与内存布局无关
- 直接引用:可以是指向目标的指针、相对偏移量或者是间接句柄,和内存布局相关
- 会对可访问性进行检查,即权限检查。
- 类或接口的解析
- 可能会触发元数据类的加载,如父类
- 数组类型的话,先解析元素类型,然后JVM会自动生成数组类型
- 字段解析:
- 非严格解析:解析规则:本类 => 接口 =>父类 , 从下往上递归,可得一个唯一结果
- 严格解析:从下向上递归的过程中,同一级的不允许出现同名字的字段
- 方法解析、接口方法解析:从下往上递归。
- 小知识:接口允许多继承
初始化
初始化阶段就是执行 类构造器()方法的过程。
- ()的产生:按源代码顺序自动收集 类变量的赋值动作和static代码块。
- 定义在静态代码块的代码,不能访问在后面的静态变量,但可以赋值。否则出现“非法前向引用”
- ()不用显式调用父类的(),JVM会保证父类的先执行完毕。
- 由于父类的()先执行,故静态语句块要优先于子类
- ()不是必需的,如果没有类变量赋值动作和静态语句块
- 接口情况:
- 接口也有()方法
- 不会先执行父类的()方法。(只有当父接口定义的变量被使用时,父接口才会被初始化)
- 实现类初始化时,也不会执行接口的()方法。(注意:是针对初始化这个阶段来说的)
- 多线程环境下
- ()会被加锁
- 如果()在一个线程中执行过长,会造成其他线程阻塞
- 同一个类加载器下,一个类型只会被初始化一次。即多线程中,只有一个线程会真正执行()方法,被阻塞的线程被唤醒后不会执行()方法
4类加载器
基本概念
-
通过一个类的全限定名来获取描述该类的二进制字节流,实现这个动作的代码叫做类加载器
-
类的唯一性:由加载它的类加载器 和 这个类本身 一起共同确立在JVM中的唯一性。即Class文件相同,但加载类的加载器不同,那么在JVM中,生成的这两个类是不一样的
-
双亲委派模型(Parents Delegation Model)
-
工作过程:如果一个类加载器收到了类加载的请求,自己不会首先加载这个类,而是把这个请求委派给父类加载器去完成。当父类无法完成时,自己才会尝试去加载。
-
好处:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,保证基础类的唯一性
-
在JDK8中的 java.lang.ClassLoader.loadClass()方法中,实现双亲委派机制代码如下:
/** * Loads the class with the specified <a href="#name">binary name</a>. The * default implementation of this method searches for classes in the * following order: * * <ol> * * <li><p> Invoke {@link #findLoadedClass(String)} to check if the class * has already been loaded. </p></li> * * <li><p> Invoke the {@link #loadClass(String) <tt>loadClass</tt>} method * on the parent class loader. If the parent is <tt>null</tt> the class * loader built-in to the virtual machine is used, instead. </p></li> * * <li><p> Invoke the {@link #findClass(String)} method to find the * class. </p></li> * * </ol> * * <p> If the class was found using the above steps, and the * <tt>resolve</tt> flag is true, this method will then invoke the {@link * #resolveClass(Class)} method on the resulting <tt>Class</tt> object. * * <p> Subclasses of <tt>ClassLoader</tt> are encouraged to override {@link * #findClass(String)}, rather than this method. </p> * * <p> Unless overridden, this method synchronizes on the result of * {@link #getClassLoadingLock <tt>getClassLoadingLock</tt>} method * during the entire class loading process. * * @param name * The <a href="#name">binary name</a> of the class * * @param resolve * If <tt>true</tt> then resolve the class * * @return The resulting <tt>Class</tt> object * * @throws ClassNotFoundException * If the class could not be found */ protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name);//1、检查是否已经加载过,防止重复加载 if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false);//2、进行委派 } else { c = findBootstrapClassOrNull(name);//3、如果父类加载器为空,说明是Bootstrap 加载器。 } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name);//4、如果父类没有加载成功,自己尝试进行加载。 // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
-
具体类加载器
站在JVM的角度看,只有两种类加载器:
- 启动类加载器:用C++实现,是虚拟机本身的一部分,无法直接获取
- 其他所有的类加载器:用Java语言实现,全部继承至抽象类 java.lang.ClassLoader,独立于JVM外部
具体:
- Bootstrap Class Loader 启动类加载器
- 负责加载
/lib
目录 - 即使把jar包放在
/lib
目录下,不符合类库指定名也不会被加载 - 在Java中,启动类加载器用 null 值去代替
- 负责加载
- Extension Class Loader 扩展类加载器
- 负责加载
/lib/ext
目录(或者被java.ext.dirs系统变量所指定的路径中所有的类库) - 是一种Java系统类库的扩展机制
- 负责加载
- Application Class Loader 应用程序类加载器
- 负责加载
用户类路径(ClassPath)
上所有的类库 - 由于该类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,故也称为系统类加载器
- 负责加载
关系图:
他们之间的关系不是通过继承实现的,而通过组合。
(看源码可知,是通过CassLoader中的parent属性指定的)
十二、Java内存模型与线程
1概述
- 阿姆达尔Amdahl定律:通过系统中并行化与串行化的比重来描述多处理器系统能获得运算加速的能力
- 摩尔定律:描述处理器晶体管数量与运行效率之间的发展关系
2硬件的效率与一致性
-
乱序执行优化:
- 让处理器内部的运算单位尽量被充分利用
- 保证结果与顺序执行一致
- 不保证程序中各个语句计算的先后顺序与代码顺序一致
-
共享内存多核系统:每个处理器都有自己的高速缓存,同时共享同一主内存
- 带来的问题:缓存一致性
3Java内存模型
基本
- Java内存模型(Java Memory Model, JMM):屏蔽各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台下才能达到一致的内存访问效果
- 传统的C和C++是直接对物理内存进行访问的。同样的程序,可能会因机器不同产生不同的结果
主内存与工作内存
- JMM的主要目的是定义程序中各种变量的访问规则,即关注变量在内存中取出和存储的底层细节
- JMM中的变量范围
- 指的是 实例字段,静态字段,和构成数组对象的元素
- 不包括:局部变量与方法参数(对于引用类型,引用类型本身是线程私有的),因为这是线程私有的
- 也就是说,在JMM中讨论的变量范围是:公有变量。
- 主内存
- 所有变量(注意讨论范围)都存在主内存中
- 在物理上是JVM内存的一部分
- 工作内存
- 每个线程都有自己的工作内存
- 线程对变量的所有操作必须在工作内存中进行
- 工作内存中保存了该线程使用的变量的主内存副本
- 模型如下:
内存间基本交互操作
模型:
- 主内存与工作内存的具体交互协议,关注的是工作内存读取主内存中的变量以及工作内存中把变量存储到主内存的实现细节
- 通过8种操作来完成:如上图(还有lock 和 unlock)
- 这8个操作,每一个都是原子的、不可再分的
- 8个基本操作要满足一定的规则:P443
volatile关键字 (英文本意:不稳定的,波动的,易变的)
- volatile是JVM提供的最轻量级的同步机制
- 修饰变量后,变量具备两个特性:
- 保证可见性(也只能保证可见性)
- 防止指令重排
- 两个方面:保证变量赋值顺序与代码赋值顺序一致 ; 指令重排时的顺序规定(内存屏障)(不理解。。。)
- 由于Java里面的运算操作符不是原子的,故基于volatile变量的运算不是线程安全的。
- 变量在每次使用前都要进行刷新,由此保证了一致性
long和double变量特殊规则
- 基本数据类型的访问、读写是原子性的
- 但long和double 64位的变量,JVM规范允许分两次(32位)来进行操作。但实验可知,不用太在意
原子性 可见性 有序性
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特性来建立的。
- 原子性Atomicity
- read, load, assign , use, store, write 是原子的,即基本数据类型的访问、读写是原子性的。(long 和 double 除外)
- lock 和 unlock ,经过层层包装,体现如下: (基本层面:lock , unlock) => (字节码指令层面:monitorenter, monitorexit) => (代码层面:synchronized关键字,即synchronized块之间的操作也具有原子性)
- 可见性Visibility
- 定义:当一个线程修改了共享变量的值时,其他线程立即得知这个修改
- 实现方式:读取前刷新;更新后存储。
- volatile 可见性实现:读取前刷新;更新后存储
- synchronized 可见性实现:通过lock和unlock的特性实现。即lock时初始化,unlock时同步主存中P443
- final 可见性实现:final字段一旦初始化完成,就可被其他线程可见。(反正是常量,没有并发问题了)
- 有序性Ordering
- volatile实现有序性:防止指令重排
- synchronized 实现有序性:通过lock操作中的“一个变量在同一个时刻只允许一个线程对其进行lock操作”
可以发现synchronized是“万能的”
先行发生原则Happens-Before
- 先行发生指的是JMM中定义的两项操作之间的偏序关系
- P453中描述的8个先行关系,JVM对它们的先行关系是有保障的。除此之外,无法保障。
- “时间上的先发生” 和 “先行发生” 是即不充分,也不必要条件
4Java与线程
线程的实现
-
内核线程实现
-
轻量级进程与内核线程之间比例为 1:1
-
优点:(即对应着UTL的两个缺点)
- 可以应用多处理器技术。内核可以同时把同一个线程的多个线程分配到不同的处理器中
- 线程阻塞不会影响到所有的线程。处理器可以自行调用不阻塞的线程
-
缺点:
- 线程切换时,模式切换带来的时间开销。
-
模型图:
-
-
用户级线程
-
进程与用户线程之间的比例为 1:N
-
优点:
- 节省模式切换开销时间:所有的线程管理工作都在应用程序内完成,节省了用户模式和内核模式之间的转换
- 调度自由度高:可由应用程序自行决定调度算法,个性化线程执行优先级
- 可移植:由于和内核无关,那么写的多线程程序可以在不同的操作系统上运行,而不用关心具体的内核底层
-
缺点:
- 纯ULT中,不能使用多处理器技术。因为内核一次只把一个进程分配给一个处理器
- 执行系统调用时,会阻塞所有的线程(解决方案是 套管技术:先不调用产生阻塞的线程,而调用另一个不产生阻塞的线程)
-
模型图:
-
-
混合实现
-
用户线程与轻量级进程数量比为 N:M
-
结合了上述两个模型的优点,即线程切换快,还可利用系统调度。
-
模型图:
-
Java线程的实现
- Java线程实现采用哪种模型,Java虚拟机规范中没有约束
- 对于HotSpot而言
- 采用内核级模型
- 把Java每一个线程直接映射到一个操作系统原生线程来实现,而且中间没有额外的间接结构
- JVM不会干涉线程调度,全权交给OS来处理
- 线程模型只对线程的并发规模和操作成本产生影响,对Java程序本身的编码和运行过程来说,这些差异是完全透明的 => 原因是调用了线程库的统一接口
- 线程调度分类(指系统为线程分配处理器使用权的过程)
- 协同式(Cooperative Threads-Scheduling):线程执行的时间由本身决定,自己决定是否让出处理器。缺点:容易导致整个系统崩溃
- 抢占式(Preemptive Threads-Scheduling):每个线程的执行时间由系统分配
- Java采用的是抢占式线程调度
- Java线程优先级
- 是对操作系统线程优先级的一种“建议”
- 不是一种稳定的调节手段
- Java程序线程不同的优先级,映射到系统上的优先线,可能会导致相同的优先级
- 优先级可以被系统自行调节。(如频繁调用的线程,防止切换时间花销高,系统会升高其优先级)
- 线程状态
- 创建(新生)状态 创建线程对象就进入到新生状态
- 就绪状态 调用start()方法线程进入就绪状态,等待CPU调度
- 运行状态 cup调度,线程执行线程体的代码块
- 阻塞状态 当调用sleep,wait或同步锁定时,线程进入阻塞状态,代码不能往下执行,阻塞事件解除后,重新进入就绪状态
- 死亡状态 线程中断或者结束,一旦进入死亡状态,就不能再次启动
- 等待。(有限期 和 无限期)
5Java与协程
-
背景:由于Java一次性处理的请求过多,导致线程数激增。而本身是采用1:1内核级线程模型,切换开销大,速度慢。于是协程逐渐复苏
-
“协程”名称来源:最初多数的用户线程被设计成协同式调度,故得名。分为有栈协程与无栈协程
-
Java纤程(Fiber):一种有线协程的实现
- 是线程用户模型 1: N
官方描述如下:
What is a fiber ?
- A light weight or user mode thread, scheduled by the Java virtual machine, not the operating system
- Fibers are low footprint and have negilgible task-switching overhead. You can hava millions of them!
一个轻量级或用户模式线程,由Java虚拟机而不是操作系统调度。
协程占用空间小,任务切换开销可以忽略不计。你可以拥有数百万个!
-
目前还未引入纤程,但未来很可能会出现 纤程模型 和 现有线程模型 共存的局面。即用户级线程模型与内核级线程共存,即混合模型N:M