时间:2023-07-02 04:48:01 | 来源:网站运营
时间:2023-07-02 04:48:01 来源:网站运营
Java虚拟机(JVM)你只要看这一篇就够了!:本文是学习了《深入理解Java虚拟机》之后的总结,主要内容都来自于书中,也有作者的一些理解。一是为了梳理知识点,归纳总结,二是为了分享交流,如有错误之处还望指出。根据《Java 虚拟机规范(Java SE 7 版)》规定,Java 虚拟机所管理的内存如下图所示。
内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
线程私有,生命周期和线程一致。描述的是 Java 方法执行的内存模型:每个方法在执行时都会床创建一个栈帧(Stack Frame)用于存储局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)局部变量表
、操作数栈
、动态链接
、方法出口
等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。也会有 StackOverflowError 和 OutOfMemoryError 异常。1.1.4 Java 堆
对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享,主要是存放对象实例和数组。内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。
属于共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。现在用一张图来介绍每个区域存储的内容。
属于方法区一部分,用于存放编译期生成的各种字面量和符号引用。编译器和运行期(String 的 intern() )都可以将常量放入池中。内存有限,无法申请时抛出 OutOfMemoryError。1.1.7 直接内存
非虚拟机运行时数据区的部分在 JDK 1.4 中新加入 NIO (New Input/Output) 类,引入了一种基于通道(Channel)和缓存(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。可以避免在 Java 堆和 Native 堆中来回的数据耗时操作。
主要介绍数据是如何创建、如何布局以及如何访问的。1.2.1 对象的创建
创建过程比较复杂,建议看书了解,这里提供个人的总结。遇到 new 指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。
在 HotSpot 虚拟机中,分为 3 块区域:对象头(Header)
、实例数据(Instance Data)
和对齐填充(Padding)
对象头(Header)
:包含两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,32 位虚拟机占 32 bit,64 位虚拟机占 64 bit。官方称为 ‘Mark Word’。第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以。实例数据(Instance Data)
:程序代码中所定义的各种类型的字段内容(包含父类继承下来的和子类中定义的)。对齐填充(Padding)
:不是必然需要,主要是占位,保证对象大小是某个字节的整数倍。使用对象时,通过栈上的 reference 数据来操作堆上的具体对象。通过句柄访问
Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。详情见图。
reference 中直接存储对象地址
// 待填
程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。
在进行内存回收之前要做的事情就是判断那些对象是‘死’的,哪些是‘活’的。2.2.1 引用计数法
给对象添加一个引用计数器。但是难以解决循环引用问题。
通过一系列的 ‘GC Roots’ 的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连的时候说明对象不可用。
前面的两种方式判断存活时都与‘引用’有关。但是 JDK 1.2 之后,引用概念进行了扩充,下面具体介绍。下面四种引用强度一次逐渐减弱
类似于 Object obj = new Object();
创建的,只要强引用在就不回收。
软引用SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。弱引用
WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。虚引用
PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。2.2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“facebook”的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。2.2.5 回收方法区
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象简历关联即可。
finalize() 方法只会被系统自动调用一次。
在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而永久代的垃圾收集效率远低于此。判断废弃常量:一般是判断没有该常量的引用。
永久代垃圾回收主要两部分内容:废弃的常量和无用的类。
仅提供思路2.3.1 标记 —— 清除算法
直接标记清除就可。两个不足:
把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面。解决前一种方法的不足,但是会造成空间利用率低下。因为大多数新生代对象都不会熬过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间。大小比例一般是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间。但是这里有一个问题就是如果存活的大于 10% 怎么办?这里采用一种分配担保策略:多出来的对象直接进入老年代。
不同于针对新生代的复制算法,针对老年代的特点,创建该算法。主要是把存活对象移到内存的一端。2.3.4 分代回收
根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。新生代
每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。老年代
老年代中对象存活率较高、没有额外的空间分配对它进行担保。所以必须使用标记 —— 清除
或者标记 —— 整理
算法回收。
// 待填
收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。
这是一个单线程收集器。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。
可以认为是 Serial 收集器的多线程版本。
指多条垃圾收集线程并行工作,此时用户线程处于等待状态并发:Concurrent
指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个 CPU 上运行。2.5.3 Parallel Scavenge 收集器
这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
收集器的老年代版本,单线程,使用 标记 —— 整理
。
Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,使用 标记 —— 整理
CMS (Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。基于 标记 —— 清除
算法实现。
运作步骤:标记 —— 清除
算法带来的空间碎片面向服务端的垃圾回收器。优点:并行与并发、分代收集、空间整合、可预测停顿。
对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中。一般来说 Java 堆的内存模型如下图所示:
发生在新生代的垃圾回收动作,频繁,速度快。老年代 GC (Major GC / Full GC)
发生在老年代的垃圾回收动作,出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢十倍以上。2.6.2 大对象直接进入老年代
屏蔽掉各种硬件和操作系统的内存访问差异。
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。一个变量被定义为 volatile 的特性:
如果不符合运算结果并不依赖变量当前值,或者能够确保只有单一的线程修改变量的值
和变量不需要与其他的状态变量共同参与不变约束
就要通过加锁(使用 synchronize 或 java.util.concurrent 中的原子类)来保证原子性。
通过插入内存屏障保证一致性。3.1.3 对于 long 和 double 型变量的特殊规则
Java 要求对于主内存和工作内存之间的八个操作都是原子性的,但是对于 64 位的数据类型,有一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。这就是 long 和 double 的非原子性协定。3.1.4 原子性、可见性与有序性
回顾下并发下应该注意操作的那些特性是什么,同时加深理解。
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为基本数据类型的操作是原子性的。同时 lock 和 unlock 可以保证更大范围操作的原子性。而 synchronize 同步块操作的原子性是用更高层次的字节码指令 monitorenter 和 monitorexit 来隐式操作的。
是指当一个线程修改了共享变量的值,其他线程也能够立即得知这个通知。主要操作细节就是修改值后将值同步至主内存(volatile 值使用前都会从主内存刷新),除了 volatile 还有 synchronize 和 final 可以保证可见性。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步会主内存中( store、write 操作)”这条规则获得。而 final 可见性是指:被 final 修饰的字段在构造器中一旦完成,并且构造器没有把 “this” 的引用传递出去( this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见 final 字段的值。
如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。Java 语言通过 volatile 和 synchronize 两个关键字来保证线程之间操作的有序性。volatile 自身就禁止指令重排,而 synchronize 则是由“一个变量在同一时刻指允许一条线程对其进行 lock 操作”这条规则获得,这条规则决定了持有同一个锁的两个同步块只能串行的进入。3.1.5 先行发生原则
也就是 happens-before 原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。天然的先行发生关系
直接由操作系统内核支持的线程,这种线程由内核完成切换。程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口 —— 轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都有一个内核级线程支持。
广义上来说,只要不是内核线程就可以认为是用户线程,因此可以认为轻量级进程也属于用户线程。狭义上说是完全建立在用户空间的线程库上的并且内核系统不可感知的。
直接看图
平台不同实现方式不同,可以认为是一条 Java 线程映射到一条轻量级进程。3.2.2 Java 线程调度
线程执行时间由线程自身控制,实现简单,切换线程自己可知,所以基本没有线程同步问题。坏处是执行时间不可控,容易阻塞。抢占式线程调度
每个线程由系统来分配执行时间。3.2.3 状态转换
创建后尚未启动的线程。
Runable 包括了操作系统线程状态中的 Running 和 Ready,也就是出于此状态的线程有可能正在执行,也有可能正在等待 CPU 为他分配时间。
出于这种状态的线程不会被 CPU 分配时间,它们要等其他线程显示的唤醒。以下方法会然线程进入无限期等待状态:
处于这种状态的线程也不会分配时间,不过无需等待配其他线程显示地唤醒,在一定时间后他们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
线程被阻塞了,“阻塞状态”和“等待状态”的区别是:“阻塞状态”在等待着获取一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
已终止线程的线程状态。
// 待填
// 待填有点懒了。。。先贴几个网址吧。
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、装换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。在 Java 语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。
public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 1127;} public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); }} public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world!"} public class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); /** * output : SuperClass init! * * 通过子类引用父类的静态对象不会导致子类的初始化 * 只有直接定义这个字段的类才会被初始化 */ SuperClass[] sca = new SuperClass[10]; /** * output : * * 通过数组定义来引用类不会触发此类的初始化 * 虚拟机在运行时动态创建了一个数组类 */ System.out.println(ConstClass.HELLOWORLD); /** * output : * * 常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类, * 因此不会触发定义常量的类的初始化。 * “hello world” 在编译期常量传播优化时已经存储到 NotInitialization 常量池中了。 */ }}
是连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。文件格式验证
这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含 static 修饰的变量不含实例变量)。
public static int value = 1127;
这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
前面过程都是以虚拟机主导,而初始化阶段开始执行类中的 Java 代码。6.3 类加载器
通过一个类的全限定名来获取描述此类的二进制字节流。6.3.1 双亲委派模型
从 Java 虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)
keyword:线程上下文加载器(Thread Context ClassLoader)
前面两次粗略的阅读,能理解内容,但是很难记住细节。每每碰到不会的知识点就上网查,所以知识点太碎片脑子里没有体系不仅更不容易记住,而且更加容易混乱。但是通过这种方式记录发现自己清晰了很多,就算以后忘记,知识再次捡起的成本也低了很多。
这次还有一些章节虽然阅读了,但是还未完成记录。等自己理解深刻有空闲了就再次记录下来,这里的内容均出自周志明老师的《深入理解 Java 虚拟机》,有兴趣的可以入手纸质版。
关键词:虚拟