《深入理解Java虚拟机(第2版)》-笔记
分类:
文章
•
2024-12-28 16:12:04
-
内存区域
- 虚拟机自动内存管理机制
-
运行时数据区域
-
jvm在执行java程序过程中,把它所管理的内存划分为若干个不同的数据区域。

-
1. 程序计数器(Program counter register)
- 当前线程所执行的字节码的行号指示器(Native方法,则为空)
- 每条线程都需要一个独立的程序计数器(线程私有内存)
- 是唯一一个在java虚拟机规范中,没有规定任何OutOfMemoryError情况的区域。
-
2. 虚拟机栈
- 线程私有,生命周期与线程相同
-
描述Java方法执行的内存模型
- 每个方法执行同时,会创建一个栈帧(Stack Frame)
- 每个方法从调用直至完成,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
-
存储局部变量表,操作数栈,动态链接,方法出口等
-
局部变量表
- 存放编译期可知的各种基本数据类型,对象引用。
- 64位长度的long和double占用2个局部变量空间,其余数据类型只占用1个
- 当进入一个方法,需要在帧中分配多大的局部变量空间是完全确定的,运行期间不会改变。
- 线程请求栈深度大于虚拟机所允许的深度,StackOverflowError
- 如果扩展时无法申请到足够的内存,OutOfMemoryError
-
3. 本地方法栈
- 与虚拟机栈非常相似,为Native方法服务
- HotSpot将它与虚拟机栈合二为一
-
4. 堆
- 是被所有线程共享的一块内存区域,在虚拟机启动时创建,唯一目的就是存放对象实例。
- 随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换使所有对象都分配到heap上,变得不那么绝对
-
垃圾收集器管理的主要区域
-
现在GC基本都采用分代收集算法
-
新生代
- Eden
- From Survivor
- To Survivor
- 老年代
- 从内存分配角度,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(TLAB)
- Heap可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
- (-Xmx -Xms),OutOfMemoryError
-
5. 方法区
- 是各个线程共享的内存区域,用于存储类信息,常量,静态变量,JIT编译后的代码等,alias:Non-heap,堆的一个逻辑部分
-
HotSpot上被人称为永久代(Permanent Generation,将GC分代收集扩展至方法区)
- 这样更容易遇到内存溢出问题,(-XX:MaxPermSize)
- JDK8中已移除,变为元数据区
- 这个区域的内存回收主要是针对常量池回收和对类型卸载,但收效甚微。
- OutOfMemoryError
-
运行时常量池
- 是方法区的一部分,运行时常量池相对Class文件常量池另一个重要特征:动态性
- 不一定只有编译期才能产生,运行期间也可能将新的常量放入池中。
-
6. 直接内存
- 并不是虚拟机运行时数据区的一部分
- NIO使用Native库直接分配堆外内存,然后通过一个Heap中的DirectByteBuffer对象作为这块内存的引用,避免来回复制数据。
- 各个内存区域大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError。
-
对象管理
- java作为一门面向对象的编程语言,在运行过程中无时无刻都有对象被创建出来。
-
为新生对象分配内存
-
指针碰撞(Bump the Pointer)
- 假设堆中内存足够规整,用过与空闲内存各在一方,分配内存仅仅就是向空闲内存方移动指针。
- 垃圾收集器是否带有压缩整理功能,决定堆是否规整。
- Serial,ParNer等带Compact过程。
-
空闲列表(Free List)
- 维护一个列表,记录哪些内存块是可用的
- 使用基于Mark-Sweep算法的收集器,CMS
-
并发情况下,内存分配
- 采用CAS搭配失败重试,保证更新操作原子性
- 每个线程预先在堆中分配一块内存(TLAB 本地线程分配缓冲),用完后,才进行上述同步锁定(-XX:+/-UseTLAB)
- 内存分配完成后,初始化为零值(实例字段,不赋初值就能使用)
-
对象内存布局
-
1. 对象头(Header)
-
Mark Word
- 32、64bit
- 存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标志等)
- 非固定的数据结构,以便在极小的空间内存内存储尽量多的信息(根据对象的状态复用自己的存储空间)
-
类型指针
- 对象指向他的类元数据指针(用来确定对象属于哪个类的实例)
- 如果对象是一个数组,还要有一块用于记录数组长度。
-
2. 实例数据(Instance Data)
- 对象真正存储的有效信息
- 无论是从父类继承下来,还是在子类中定义
- 存储顺序会受到:虚拟机分配策略参数(FieldsAllocationStyle)和字段在源码中定义顺序影响
- 相同宽度的字段总是被分配到一起
- 如果CompactFields参数值为True(默认),子类中较窄的变量可能会插入到父类变量的空隙之中。
-
3. 对齐填充(Padding)
- 并不是必然存在,起着占位符的作用
- Hotspot的自动内存管理系统要求,对象起始地址必须是8字节的整数倍,也就是对象大小必须是8字节的整数倍。
- 对象头部分正好是8字节的倍数(1或2倍),当实例数据没有对齐时,通过填充补全。
-
对象的访问定位
-
通过栈上的reference数据来操作堆上的具体对象
-
句柄
- 堆中会划分出一块内存作为句柄池,reference存储句柄地址。
-
-
直接指针
- 节省一次指针定位的时间开销。
- Hotspot,使用
-
- 句柄好处就是reference中存储句柄地址,对象被移动时,不需要改变reference。
-
OutOfMemoryError
- 除了程序计数器,其他运行区域都有可能发生OOM
-
1. Java堆溢出
- java.lang.OutOfMemoryError: Java heap space
- GC Roots到对象之间有可达路径,避免GC
-
处理方法
- dump出堆转储快照
-
判断出是内存泄露(Memory Leak)
-
还是内存溢出(Memory Overflow)
- 调大堆参数(-Xmx -Xms)
- 检查代码中对象分配是否可以优化
-
2. 虚拟机栈和本地方法栈溢出
- -Xss
-
线程请求的栈深度大于最大深度:StackOverflowError
- 在单线程下,无论是由于栈帧太大还是容量太小,都抛出该异常
- 栈深度达到1000-2000完全没问题
-
扩展栈无法得到足够空间:OutOfMemoryError
-
多线程下,每个线程栈分配内存越大,容易出现该异常。
- 每个进程内存有限制,(32bit-Window,2GB),2G-Xmx-MaxPermSize=jvm进程剩余容量
- 通过减少最大堆和减少栈容量,换取更多线程。
-
3. 方法区和运行时常量池的溢出
- JDK7移除字符量常量池
- 通过List保持引用,避免回收
- java.lang.OutOfMemoryError: PermGen space
- 动态增强的类可以载入内存中(CGLIB)
-
本机直接内存溢出
- 可通过-XX: MaxDirectMemorySize指定,默认与-Xmx一样。
- 发生OOM后,Dump文件很小,而程序中使用NIO。
-
垃圾收集器
- 程序计数器,虚拟机栈,本地方法栈,随线程而生而灭,因为方法结束或者线程结束时,内存自然就跟着回收了。
-
Java堆和方法区
- 只有在程序处于运行期间时才能知道会创建哪些对象。
- 内存的分配和回收都是动态的。
-
存活算法
-
引用计数算法
- 给对象添加一个引用计数器,每当一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1。
-
主流JVM没有使用引用计数算法
-
可达性分析算法 reachability Analysis
- 通过一系列“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链Reference Chain。
-
可作为GC Roots的对象
- 1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 2. 方法区中类静态属性引用的对象。
- 3. 方法区中常量引用的对象。
- 4. 本地方法栈中JNI引用的对象。
-
引用
- 判断对象是否存活都与引用相关。
- 如果对象只定义为被引用或没有被引用两种状态,就太过狭隘,描述一些“食之无味,弃之可惜”的对象就显得无能为力。
- 当内存空间足够,则保留,空间紧张,则丢弃。
-
JDK1.2+
-
1. 强引用
- Object obj = new Object(); 只要强引用存在,则GC永远不会回收该部分。
-
2. 软引用
- SoftReference类实现
- 描述还有用但并非必需的对象
- 在系统将要发生内存溢出前,将把这些对象列入回收范围进行二次回收。
-
3. 弱引用
- WeakReference
- 被弱引用关联的对象只能生存到下一次GC发生前。GC发生时,都会回收该部分对象。
-
4. 虚引用,幽灵引用,幻影引用
- PhantomReference
- 无法通过虚引用来取得一个对象实例
- 为一个对象设置虚引用关联的唯一目的,就是能在这个对象被收集器回收时收到一个系统通知。
-
生存还是死亡
- 可达性分析算法中不可达对象,并非非死不可,只是处于缓刑阶段。
-
到真正宣告一个对象死亡,至少要经历两次标记过程
-
1. 如果对象脱离引用链,那它将会被第一次标记并且进行一次筛选
-
筛选条件是此对象是否有必要执行finalize()
- 对象没有覆盖finalize(), 或者finalize()已被虚拟机调用过;这两种情况被视为没有必要执行。
- 没必要执行finalize,则回收。
-
2. 当被判定为有必要执行finalize(),那么这个对象将会放置在一个F-Queue的队列中。
- 并在稍后由一个由虚拟机自动建立的,低优先级的Finalizer线程执行(并不承诺等待它运行结束,可能会导致F-Queue其他对象永久等待)它。
- finalize()是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,只要对象在finalize中成功拯救自己(重新加入引用链)
-
一个对象的finalize方法最多只会被系统自动调用一次。
-
finalize(),是Java诞生时为了使C/C++程序员更容易接受它,所作出的一个妥协,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。
- 使用try-finally或其他方式可以做的更好。
-
回收方法区
- 在方法区中进行垃圾收集,性价比较低。
- 新生代中,进行一次GC,一般可以回收70%~95%的空间。
-
方法区的回收
-
1.废弃常量
-
2.无用类
-
同时满足3个条件,才可以回收(-Xnoclassgc)
- 该类所有实例都已被回收。
- 加载该类的ClassLoader已被回收
- 该类对应的Class对象没有被引用,无法在任何地方通过反射访问该类方法。
-
垃圾回收算法
-
标记清除算法 Mark-Sweep
- 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
-
后续的收集算法都是基于这种思路,并对其不足进行改进
- 1. 效率问题
- 2.空间问题:会产生大量不连续的内存碎片。
-
复制算法 Copying
- 将可用内存划分为大小相等的两块,每次只使用其中一块,这块内存用完了,就将还存活的对象复制到另一块,然后这块内存一次清理掉。
- 代价是将内存缩小为了原来的一半。
-
现在商业虚拟机都采用这种收集算法,来回收新生代。
-
新生代中的对象98%是朝生夕死的
-
将内存分为一块较大的Eden空间和两块较小的Survivor空间。
- 每次使用Eden和一块Survivor空间,回收时,将存活对象复制到另一块Survivor上。
- HotSpot默认Eden和Survivor比例为8:1
- 无法保证每次回收都只有不多于10%对象存活,当Survivor不够用时,需要依赖其他内存(老年代)进行分配担保
- 缺陷:对象存活率较高,就要进行较多的复制,效率变低。
-
标记-整理算法 Mark-Compact
- 根据老年代的特点,回收时不直接对可回收对象进行整理,而是让所有存活下来的对象都向一端移动。
-
分代收集算法 Generational Collection
- 当今商业虚拟机的垃圾收集都采用 分代收集算法,根据对象存活周期不同,将内存划分为几块(新生代和老年代)。
-
根据每个年代特点,采用最适当的收集算法。
- 新生代中,大批对象死去,少量存活,选用复制算法。
- 老年代,对象存活率高,没有额外空间进行担保,必须使用标记清理或标记整理算法。
-
HotSpot算法实现
-
枚举根节点
-
可作为GC Roots的节点主要在全局性的引用(常量或类静态属性)与执行上下文(栈帧中的本地变量表)中。
-
GC停顿
-
可达性分析必须在一个确保一致性的快照中进行
- 看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用还在不断变化的情况。
- 是导致GC进行时,必须停顿所有Java执行线程(Stop The World)的其中一个重要原因。
-
主流JVM使用的都是准确式GC
- 并不需要一个不漏地检查完所有执行上下文和全局的引用变量,有办法直接得知哪些地方存放着对象引用。
-
HotSpot实现中,使用一组OopMap,到了特定的位置记录着引用。
- 在OopMap协助下,HopSpot可以快速准确的完成GC枚举,如果每一条指令都生成对应OopMap,那成本会很高。
-
安全点 Safepoint
- 解决如何进入GC的问题
- 程序执行时并非在所有地方都能停顿下来进行GC,只有在到达安全点时才能暂停。
- 选点不能太少,也不能过大,以是否具有让程序长时间执行的特征为标准进行选定。
-
如何在GC发生时,让所有线程都跑到安全点上停顿下来。
-
1. 抢先式中断
- GC发生时,首先把所有线程全部中断,如果发现线程中断地方不在安全点上,就恢复线程,让他跑到安全点上。
- 现在几乎没有虚拟机采用这种方式。
-
2. 主动式中断
- 当GC时,仅仅简单设置一个标志,各个线程主动轮询这个标志,轮询标志的地方和安全点是重合的。
-
安全区域
- Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。
-
当程序不执行时,(线程处于Sleep或Blocked状态时),需要安全区域(Safe Region)
- 安全区域中引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。
- 当线程执行到Safe Region时,标识自己进入了Safe Region,那样,在这段时间里,GC发生,就不用管Safe Region状态的线程,当离开SR时,线程检查是否完成GC过程,否则必须等待直到收到可以安全离开SR的信号为止。
-
垃圾收集器
- 一般都会提供参数供用户根据自己的应用特点和要去,组合出各个年代所使用的收集器。
-
-
1. Serial收集器
- 最基本,发展历史最悠久的收集器
-
是个单线程收集器(不仅仅说明它只会使用一个线程去完成收集工作,还必须暂停其他所有工作线程,直到它收集结束STW)
-
依赖是JVM运行在Client模式下的默认新生代收集器。
- Serial收集器没有线程交互开销,专心做垃圾收集,对桌面应用管理内存不大,是个很好选择。
-
2. ParNew收集器
-
Serial的多线程版本 +XX: +UseParNewGC
-
是许多运行在Server模式下的首选新生代收集器。
- 重要原因,是除了Serial外,唯一能和CMS配合工作的。
- 默认开启的收集线程数与CPU的数量相同。
-
3. Parallel Scavenge 收集器
- 关注点与其他收集器不同,目标是达到一个可控制的吞吐量(CPU用于运行用户代码时间与CPU总消耗时间的比值)。
-
+XX:MaxGCPauseMillis,控制最大垃圾收集停顿时间
- GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,导致GC频繁。
-
+XX:GCTimeRation,直接设置吞吐量大小。(0,100)
-
+XX:UseAdaptiveSizePolicy
- 开关参数,打开后,就不需要手工指定新生代大小(-Xmn),Eden与Survivor比例。
- 虚拟机会根据当前系统运行情况收集性能监控信息,动态调整参数,GC自适应调节策略(GC Ergonomics)。
- 吞吐量优先收集器
- 4. Serial Old收集器
-
5. Paraller Old收集器
-
Paraller Scavenge老年代版本,JDK6+
- 在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old
-
CMS收集器 Concurrent Mark Sweep
- 以获取最短回收停顿时间为目标的收集器
-
基于标记清除,分为4个步骤
-
1. 初始标记
- STW,标记GC Roots能直接关联到的对象,速度很快。
-
2. 并发标记
-
3. 重新标记
- STW,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象标记记录。
- 4. 并发清除
-
整个过程耗时最长的并发标记和并发清除过程,收集器线程都可以与用户线程一起工作
-
并发低停顿收集器,3个缺点:
-
1. 对CPU资源非常敏感。
-
虽然不会导致用户线程停顿,但是会因为占用一部分线程资源而导致应用变慢。
-
为了解决这种情况,提供了增量式并发收集器
- 让GC线程与用户线程交替运行,尽量减少GC线程独占资源时间。
- 效果很一般,已不提倡使用。
- 默认回收线程数: (CPU数量+3)/4
-
2. 无法处理浮动垃圾 Floating Garbage
- 并发清理阶段,用户线程还在运行,会有新垃圾不断产生:浮动垃圾
- GC与用户进程并行执行,就还需要预留有足够的内存空间给用户进程使用
-
-XX:CMSInitiatingOccupancyFraction提高触发百分比
- JDK6+,默认阀值92%,如果预留内存无法满足需要,出现Concurrent Mode Failure失败,临时启用Serial Old收集器。
-
3. 会有大量空间碎片
- 给大对象分配带来麻烦,不得不提前触发Full GC
-
-XX:UseCMSCompactAtFullCollection,开关(默认开启)
-
-XX:CMSFullGCsBeforeCompaction
- 设置执行多少次不压缩Full GC后,跟着来一次带压缩(默认为0)
-
G1收集器 Garbage-First
-
并行与并发
- 分代收集
-
空间整合
-
可预测停顿
-
能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
- 可以有计划的避免在整个堆中进行全区域的GC
- 跟踪各个Region里垃圾堆积的价值大小,维护优先队列,根据每次允许的收集时间,优先回收价值最大的Region
-
G1在堆中内存布局
- 分为多个大小相等的独立区域(Region),不再是新老生代。
- 还保留新老生代的概率,都是一部分Region的集合(不需要连续)
- 使用每个Region对应一个Remembered Set,保证不对全堆进行扫描。
-
四个阶段
- 1. 初始标记
- 2. 并发标记
- 3. 最终标记
- 4. 筛选回收
- 如果追求低停顿,是一个可以尝试的选择,追求吞吐量,则不会有什么特别的好处。
-
理解GC日志
-
内存分配与回收策略
- 自动内存管理,给对象分配内存以及回收分配给对象的内存。
-
1. 对象优先在Eden分配
- 当Eden没有足够空间进行分配时,JVM发起一次Minor GC。
- -XX:+PrintGCDetails,发生垃圾收集行为时打印内存回收日志
-
Minor GC与Full GC/Major GC的区别
- Minor GC发生在新生代上,非常频繁,回收速度快
- Full GC发生在老年代,一般比Minor GC慢10倍以上。
-
2. 大对象直接进入老年代
- 大对象:需要大量连续内存空间的Java对象,对内存分配来说是个坏消息,提前确保有足够连续空间安置他们。
-
-XX:PretenureSizeThreshold:令大于这个设置值的对象直接在老年代分配。只对Serial和ParNew有效
-
3. 长期存活的对象将进入老年代
- 虚拟机给每个对象定义了一个对象年龄计数器。
-
对象在Eden出生,并经过第一次Minor GC存活到Survivor中,每熬过一次Minor GC年龄就加一
- -XX:MaxTenuringThreshold配置年龄阈值,默认15岁时,就被晋升到老年代中。
-
4. 动态对象年龄判定
- 并不是永远要求对象年龄必须达到MaxTenuringThreshold才能晋升老年代。
- 如果Survivor空间中相同年龄所有对象大小总和 大于Survivor空间的一半,年龄大于或等于该年龄的对象就直接进入老年代。
-
5. 空间分配担保
-
在发生Minor GC前,JVM会先检查老年代最大可用的连续空间是否大于新生代所有对象空间。
- 如果成立,则Minor GC可以确保是安全的
-
如果不成立,JVM查看HandlePromotionFailure设置值是否允许担保失败
-
允许,继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。
-
如果大于,将进行一次Minor GC,存在风险
- 小于或HandlePromotionFailure不允许,则改为进行一次Full GC
- 取平均值进行比较,是一种动态概率手段
-
如果担保失败,则重新发起一次Full GC
- 大部分情况还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
- 内存回收和垃圾收集器很多时候都是影响系统性能,并发能力的主要因素之一。
-
类加载机制
-
Java里天生可以动态扩展的语言特性,就是依赖运行期间动态加载和动态连接这个特点实现的。
- 面向接口的应用程序,可以等到运行时再指定其实际的实现类。
-
类加载时机
- 类从被加载到虚拟机内存中开始,到卸载出内存为止,生命周期包括7个阶段:
-
- 加载,验证,准备,初始化和卸载五个阶段的顺序是确定的。
-
而解析阶段则不一定
- 正常在准备阶段后开始
- 为了支持Java语言的运行时绑定,在初始化阶段后再开始
- 这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用**另一个阶段。
- 按部就班的开始,不意味着完成后才开始。
-
1. 加载
-
在加载阶段,虚拟机需要完成以下3件事
-
1. 通过一个类的全限定名,来获取定义此类的二进制字节流。
-
许多举足轻重的Java技术都建立在这一基础上。
-
这个动作放到Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类,“类加载器”
- 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。
- 比较两个类是否相等,只有在这个两个类是由同一个类加载器加载的前提下,才有意义。
- 2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3. 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据访问入口。
- 数组类本身不通过类加载器创建,由虚拟机直接创建,但数组的元素类型最终是要靠类加载器去创建的。
- 加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中
-
然后在内存中实例化一个Class对象,HotSpot存放在方法区中。
- 这个对象将作为程序访问方法区中的这些类型数据的外部接口。
-
2. 验证
- 确保Class文件的字节流中包含的信息符合当前虚拟机的要求
- 如果输入字节流不符合约束,则抛出java.lang.VerifyError。
-
4个阶段检验:
-
1. 文件格式验证
- 基于二进制字节流,验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
- 只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储。
- 后面的3个阶段都是基于方法区的存储结构进行,不会再直接操作字节流。
-
2. 元数据校验
-
3. 字节码验证
- 最复杂的一个阶段 ,通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。
- Halting Problem:通过程序去校验程序逻辑是无法做到绝对准确的。
- 由于数据流验证的高复杂性,JDK6+后,给方法体中新增StackMapTable属性,记录应有的状态,这样就将类型推导转变为类型检查,从而节省时间。
-
4. 符号引用验证
- 发生在虚拟机将符号引用转化为直接引用的时候,在解析阶段发生。
- 访问权限是否满足 ...
- 如果所运行的全部代码都已经被反复使用和验证过,可在实施阶段使用-Xverify:none关闭大部分类验证措施。
-
3. 准备
-
是正式为类变量分配内存并设置类变量初始值的阶段
- 这时候进行内存分配的仅包括类变量(static),不包括实例变量。
- public static int value = 123, 准备阶段过后,value值为0,赋值123将在初始化阶段执行。
- 若存在ConstantValue属性,public static final int value=123;将在准备阶段被赋值123.
-
4. 解析
- 虚拟机将常量池中符号引用替换为直接引用的过程。
- 1. 类或接口解析
-
2.字段解析
- 实现接口,将会按照继承关系从下往上递归搜索。
- 找不到则抛出NoSuchFieldError异常
- 若对字段无权限,则抛出IllegalAccessError
- 3. 类方法解析
- 4. 接口方法解析
-
5. 初始化阶段
- 到了初始化阶段,才真正开始执行类中定义的Java代码。
-
初始化阶段是执行类构造器<clinit>()方法的过程。
-
由编译器自动收集类中,所有的类变量的赋值动作和静态语句块的语句合并产生的。
- 收集顺序由语句在源文件中出现的顺序所决定
-
非法向前引用
-
静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
- 不需要显示调用父类构造器,虚拟机会保证在子类<clinit>方法执行前,父类的<clinit>已经执行完毕
- 对于类或接口不是必须的。
- 虚拟机保证<clinit>方法在多线程环境下,被正常加锁同步,其余线程都需要阻塞等待。
-
有且只有5种情况,必须立即对类进行初始化(对一个类进行主动引用)
-
1. 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时。
- 使用new实例对象,读取或设置一个类静态变量(被final修饰,在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法。
- 2. 使用reflect包方法,对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化。
- 3. 当初始化一个类,发现父类还未初始化,先触发其父类初始化
- 4. 当虚拟机启动,用户需要指定一个要执行的主类(包含main方法的那个类),先初始化这个主类。
- 5. 使用JDK7的动态语言支持时,java.lang.invoke.MethodHandle,方法的句柄所对应类没有进行初始化时
-
被动引用
- 除主动引用外,所有引用类的方法都不会触发初始化
-
1. 通过子类引用父类的静态字段,不会导致子类初始化。
- 对于静态字段,只有直接定义这个字段的类才会初始化。
- HotSpot通过-XX:+TraceClassLoading可观察到此操作会导致子类的加载。
-
2. 通过数组定义来引用类,不会触发类的初始化
- 由虚拟机自动生成的,直接继承于Object的子类,创建动作有字节码指令newarray触发。
- 这个[L...数组专属类,封装了数组中属性和方法,是Java中对数组访问比C/C++相对安全的原因。
-
3. 常量在编译阶段就存入调用类的常量池中,调用时不会触发初始化
-
接口与类初始化场景,存在一处不同
- 当类被初始化时,要求其父类都被初始化过了,而接口初始化时,并无此要求,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
-
双亲委派模型
-
从JVM角度,只存在两种不同的类加载器
-
1.启动类加载器(Bootstrap ClassLoader),由C++实现。
- 负责将存在<JAVA_HOME>/lib目录中或-Xbootclasspath的被JVM识别的类库,加载到虚拟机内存中。
- 无法被Java程序直接引用,如果需要把加载请求委派给它,直接使用null替代即可。
-
2. 所有其他的类加载器,都继承自ClassLoader,独立于虚拟机外部。
-
扩展类加载器(Extension ClassLoader)
- 负责加载<JAVA_HOME>\lib\ext目录中或被java.ext.dirs系统变量所指定的类库,开发者可直接使用。
-
应用程序类加载器(Application ClassLoader)
- 加载用户类路径(ClassPath)上所指定的类库,如果没有自定义过自己的类加载器,程序中默认的类加载器。
-
除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
- 这里的类加载器之间的父子关系,一般不会以继承实现,而是使用组合。
- 并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。
-
工作过程
- 如果一个类加载器收到了类的加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类的加载器去完成。
- 因此所有的加载请求都应该传送到顶层的启动类加载器中。
- 只有父加载器无法完成这份加载请求,子加载器才会尝试自己去加载。
-
好处
- Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
- 与rt .jar类库中重名的Java类,将会正常编译,但永远无法被加载运行。
- 如果父类加载器加载失败,则抛出ClassNotFoundException
-
破坏双亲委派模型
-
1. 第一次被破坏是在JDK2之前,还未出现双亲委派模型。
- 不提倡用户覆盖loadClass方法(实现双亲委派模型),而应当把自己的类加载器写到findClass方法中。
-
2. 由这个模型自身的缺陷所导致。
- 基础类又要调用用户自己的代码。
-
JNDI,线程上下文加载器。通过Thread类的setContextClassLoaser方法设置。
- 如果创建线程时还未设置,它将会从父线程中继承一个,如果全局范围都没有设置,则这个类加载器默认就是应用程序类加载器。
- 父类加载器,请求子类加载器完成类加载动作,所有涉及SPI的加载,如JNDI,JDBC等
- 3. 由于用户对程序动态性的追求而导致的。
- 除了加载阶段用户程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
-
常用命令行
- 几乎所有工具的体积基本上都稳定在27kb左右,仅仅是jdk/lib/tools.jar的一层薄封装。
-
jps,JVM Process Status
- jps -lv
- l: 输出主类的全名
- v: 输出虚拟机进程启动时JVM参数
-
jstat ,JVM Statistics Monitoring Tool
- 用于监视虚拟机各种运行状态信息的工具,定位虚拟机性能问题首选工具
- jstat [option vmid [interval[s|ms] [count]] ]
- option: 分为3类:类加载,垃圾收集,运行期编译状况
- -gcutil 已使用空间占总空间的百分比
- -gc 监视Java堆状况
- jstats -gcutil 2032
-
jinfo, Configuration Info for Java
- 实时查看和调整虚拟机各项参数,常用于未被显式指定的参数
- -flag name 未被显示指定参数
- -sysprops System.getProperties()的信息
- jinfo [option] pid
- jinfo -flag CMSInitiatingOccupancyFraction 123
-
jmap, Memory Map for Java
- 用于生成堆转储快照(获取dump文件,查询finalize队列,Java堆信息等)
- 可使用-XX: +HeapDumpOnOutOfMemoryError/ -XX:+HeapDumpOnCtrlBreak,生成dump文件
- jmap [option] vmid
- -dump:[live,]format=b, file=<filename>
- -heap,显示堆详细信息
- -histo 显示堆中对象统计信息
- jmap -dump:format=b,file=log.bin 3500
-
jhat,JVM Heap Analysis Tool
- 搭配jmap使用,内置微型HTTP/HTML服务器
- 分析工作耗时而且消耗硬件资源的过程,在服务器上使用较少
-
jstack,Stack Trace for Java
- 生成虚拟机当前时刻的线程快照,每一条线程正在执行的方法堆集合
- 定位线程出现长时间停顿的原因
- jstack -l vmid
- -l: 除堆栈外,显示关于锁的附加信息
-
线程模型
-
happens-before
- 判断数据是否存在竞争,线程是否安全的主要依据。
- 无须任何同步手段保障。
-
8种JMM先行关系
- 程序次序规则,一个线程内,按照控制流顺序执行。
- 管程锁定规则,同一个锁,unlock先行发生于lock。
- volatile变量规则,volatile变量的写操作先行于读。
- 线程启动规则,线程的start先行于此线程的每个动作。
- 线程终止规则,线程所有操作先行于对此线程的终止检测(join,isAlive()).
- 线程中断规则,对线程的interrupt()方法的调用,先行于检测中断时间的发生(Thread.interrupted())
- 对象终结规则,对象的初始化完成先行于finalize()方法开始
- 传递性,a先于b,b先于c,a必先于c。
- 时间先后顺序与先行发生原则没有太大关系,一切必须以先行发生原则为准。
-
Thread类中所有关键方法都是Native,实现线程3种方式:
- 线程模型,只对线程的并发规模和操作成本产生影响,对编码和运行,这些差异都是透明的。
-
1. 使用内核线程(KLT),Multi-Threads-Kernel通过操纵调度器对线程进行调度,并负责将线程任务映射到各个处理器上。
- 程序不直接使用内核线程,使用内核线程的高级接口(轻量级进程Light Weight Process LWP)
-
轻量级进程与内核进程,一对一的线程模型
- 优势:LWP成为独立调度单元,单个阻塞也不影响整个进程继续。
-
缺陷:
- 系统调用代价高,需要在用户态和内核态中来回切换
- 系统支持轻量级进程的数量是有限的,LWP要消耗一定的内核资源。
-
2. 用户线程,完全建立在用户空间线程库上,系统内核无感知。
- 只要程序实现得当,就不需要切换到内核态,操作非常快速低耗。
-
进程与用户线程间一对多的线程模型
-
优势与劣势都在于有无系统内核的支援。
- 所有的线程操作都需要用户程序自己处理,异常麻烦。
- Java曾经使用过,已放弃(除了DOS中)
-
3. 用户进程加轻量级进程混合实现
- 用户线程仍创建在用户空间中,通过轻量级进程作为与内核线程的桥梁。
- 可以使用内核提供的线程调度和处理器映射,与用户线程的廉价操作。
-
用户线程与轻量级进程间N:M模型
-
4. java线程实现
-
JDK1.2之前
-
JDK1.2+
- 替换为基于操作系统原生线程模型实现
- Windows/Linux 使用一对一的线程模型
- Solaris可配置一对一/多对多模型
-
线程调度
- 系统为线程分配处理器使用权的过程
-
1. 协同式Cooperative
- 线程使用时间由线程本身控制,线程把自己的事干完后进行线程切换,切换操作对线程自己可知。
- 缺陷:执行时间不可控制,可能导致一直阻塞。
-
2. 抢占式Preemptive
- 每个线程将由系统来分配执行时间,系统可控。
- java使用此。
-
设置优先级,可对分配时间提出建议。
- 10个级别,优先级越高,越容易被选中执行。
- 不靠谱,线程调度最终还是取决于操作系统,每个平台自身的线程优先级与jvm提供的不对应。
- 优先级推进器(Priority Boosting)
-
5类操作共享的数据
- 1. 不可变Immutable,只要不可变对象被正确的构建出来,并没有发生this引用逃逸,就一定是线程安全的。
-
2. 绝对线程安全,不意味着调用它时不需要,e.g. Vector多线程remove()
- java API中标注为线程安全的类,大多都不是绝对线程安全的,
-
3. 相对线程安全,通常意义所讲的线程安全,对象的单独操作是线程安全的。
- 4. 线程兼容,对象本身并不是线程安全的,通过使用同步手段,达到线程安全。
- 5. 线程对立,无论是否采取同步措施,都无法在并发中的代码。e.g. suspend/resume()
-
线程安全的实现方法(虚拟机)
-
1. 互斥同步(阻塞同步)
- 临界区、互斥量、信号量是主要互斥实现方式。
-
synchronized
-
会在同步块前后分别形成monitorenter和monitorexit两个字节码指令。
-
需要一个reference类型参数指明要锁定和解锁的对象
- monitorenter锁计数器加1,exit减1,为0锁释放
- 同一线程可重入。虚拟机在未来性能改进中,更加偏向于原生的synchronized。
-
2. 非阻塞同步
-
随着硬件指令集的发展,基于冲突检测的乐观并发策略。
- 先进行操作,产生了冲突,再采取补偿措施(不断重试)。
-
需要保证操作和冲突检测具备原子性
- 必须从硬件角度保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。
- JDK1.5后,由sun.misc.Unsafe实现CAS操作,无法解决ABA问题,AtomicStampedReference过于鸡肋。
-
3. 无同步方案
-
可重入代码
-
线程本地存储
- e.g. ThreadLocal, 经典web交互模型中,一个请求对应一个服务器线程(Thread-per-Request)
-
锁优化
- 高效并发,是从jdk1.5到jdk1.6的一个重要改进,为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
-
自旋锁与自适应自旋
- 忙循环(自旋)
- 挂起和恢复线程的操作都需要转入内核态中完成
- 如果物理机上有一个以上处理器,可以让后面请求锁的那个线程,稍等一下,但不放弃处理器的执行时间,看看锁会不会被快速释放。
- JDK1.6中已默认开启
-
缺陷
- 需要占用处理器时间,锁占用时间长,会白白消耗处理器资源。
- JDK1.6之前,自旋次数的默认值是10次,超过后,则挂起线程,可通过-XX:PreBlockSpin更改
- JDK1.6+,引入自适应自旋锁,时间不在固定,由上一次在同一个锁上的自旋时间及锁的拥有者状态来决定。
-
锁消除
- 虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争(逃逸分析)的锁进行消除。
- jdk1.5之前,String的+拼接使用StringBuffer(含有同步块锁),1.5+,改为StringBuilder
-
锁粗化
- 虚拟机探测到对同一对象零碎加锁,会扩大加锁同步的范围。
-
轻量级锁
- 1.6中加入的新型锁机制,传统使用操作系统互斥量来实现的被称为重量级锁。
-
HotSpot对象头,与对象自身定义数据无关的额外存储成本。
- 代码进入同步块后,如果同步对象没被锁定,虚拟机在当前线程的栈帧中建立Lock Record,用于存储锁对象目前的Mark Word,即Displaced Mark Word
-
然后,虚拟机使用CAS操作,尝试将对象的Mark Word更新为指向Lock Record的指针
- 更新成功,则加锁成功
-
更新失败
- 对象的Mark Word是否指向当前线程的栈帧,如果是,则说明当前线程已拥有这个对象的锁,直接进入同步块
- 否,说明锁对象被其他线程抢占,膨胀为重量级锁(多个线程争用同一个锁)
-
解锁也依赖于CAS
- 将对象当前的Mark Word和Displaced Mark Word替换回来,如果失败,说明其他线程尝试获取该锁,那么就要在释放锁的同时,唤醒被挂起的线程??
- 对于绝大部分的锁,在整个同步周期内部是不存在竞争的。
- 在无竞争的情况下使用CAS操作去消除同步使用的互斥量。
- 如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,轻量级锁会比传统重量级锁更慢。
-
偏向锁
- 在无竞争的情况下,把整个同步都消除掉。
- 锁偏向于第一个获得它的线程,JDK1.6默认开启,-XX:+UseBiasedLocking
- 使用CAS将获取到锁的线程ID记录在Mark Word中,并标记为偏向锁,持有偏向锁的线程以后每次进入这个锁相关的同步块,都不再进行任何同步操作。
- 另一个线程尝试获取这个锁时,偏向模式结束,撤销偏向revoke bias,恢复到未锁定或轻量级锁。
- 效益权衡Trade Off
-
- 能够写出高伸缩性的并发程序是一门艺术。