读《深入理解Java虚拟机》笔记

  • Java 与 C++之间有一堵由内存动态分配和垃圾回收技术围成的"高墙",墙外面的人想进去,墙里面的人想出来。

一、JVM原理

我们都知道Java源文件,通过编译器,能够产生相应的 .Class文件,也就是字节码文件,而字节码文件又通过Java虚拟机中的解释器,编译成特定机器上的机器码。每一种平台的解释器是不同的,但是实现虚拟机是相同的,所以java也就为什么能跨平台的原因了。
在这里插入图片描述

事实上,Java是一种技术,它由四方面组成:Java编程语言、Java类文件格式、Java虚拟机和Java应用程序接口(Java API)。

二、内存区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分位若干个不同的数据区域。这些区域有各自不同的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

在这里插入图片描述

1、程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。

  • 程序计数器是线程私有的。

2、Java虚拟栈

每一个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态连接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种数据类型,对象引用(这里不是对象的本身,可能是一个纸箱对象起始地址的引用指针,也可能指向一个代表对象句柄或其他与此相关的位置),和returnAddress类型(指向一条字节码指令的地址)。

  • Java虚拟栈也是线程私有的

3、本地方法栈

本地方法栈与虚拟机栈所发挥的作用非常相似的,不同在于虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。有些虚拟机把本地方法栈和虚拟机栈合二为一,两者本身区别不是很大。

4、Java堆

对于大多数的应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐不是那么“绝对”了。Java堆是垃圾回收的主要区域。
Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续的即可,就像我们的磁盘空间一样。当前主流的虚拟机可以按照可扩展来实现的(通过 -Xmx 和 -Xms控制)。

  • Java堆是线程共享的一块内存区域。

5、方法区

方法区用于储存已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。程序开发者更愿意把方法区称为“永久代”,本质上两者并不等价。

  • 方法区是线程共享的内存区域。

6、运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

三、对象的创建

(1)对象的创建
在语言层面上,创建对象(例如克隆,反序列化)通常仅仅是一个new关键字而已,而在虚拟机中,当遇见一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有那么必须先执行相应的类加载过程。类加载通过后,接下来虚拟机将为新生对象分配内存。内存的分配方式有"指针碰撞"(堆内存绝对规整),“空闲列表”(不规整的)。选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。然后虚拟机要对对象进行必要的设置,如:这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。从虚拟机的视角来看,一个新的对象已经产生了,但是,从Java程序视角来看,对象创建才刚刚开始,init方法还没执行,所有的字段都还是为零。执行new指令之后会接着执行init方法,把对象按照程序员的意愿进程初始化,这样一个真正可用的对象才算完全产生出来。

(2)对象的内存布局

虚拟机中存储的布局可以分成三块区域:对象头,实例数据,对齐填充。

对象头:一部分用于存储对象自身的运行时数据,如哈希吗(HashCode)、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过确定这个对象是哪个类的实例。

实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分存储会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。

对齐填充:并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

四、垃圾回收与内存分配策略

1、对象已死吗?

在垃圾回收之前需要确定的是那些对象还"活着",那些对象"已死"。怎么确定一个对象已经“死”了,可以被回收掉。要真正宣告一个对象死亡,至少要经历两次标记的过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某一个类变量或者对象的成员变量,那在第二次标记时它将被移除出"即将回收"集合;如果对象这时候没有逃脱,那基本上就被回收了。

很多人认为方法区(永久代)是没有垃圾收集的,但是永久代也会回收两部分内容:废弃的常量和无用的类。回收废弃的常量与回收Java堆中的对象非常类似。但是回收一个“无用的类”,这个类必须满足:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在改类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    以上只是满足“可以”,而并不是和对象一样,不使用就必然会回收。是否对类进行回收,虚拟机提供了 -Xnoclassgc参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。(以上参数可能会根据不同版的虚拟机参数会不同)。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类的卸载功能,以保证永久代不会溢出。

2、垃圾回收算法

标记清除算法:首先标记(怎么标记上文已经提到过)出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。缺点:效率低;会产生大量不连续的内存碎片,空间碎片太多会导致再分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法:内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块用完了就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。缺点:复制操作影响效率,而且浪费空间。

标记-整理算法:与标记清除算法中一样,但是不是直接对可回收对象进行清理,而是让存活的对象向一段移动。然后直接清理掉边界以外的内存。

分代收集算法:根据对象存活周期的不同将内存划分为几块。一般把java堆分为新生代和老年代。在新生代有大量对象死亡,少量对象存活,就采用复制算法。而老年代对象存活率高,就采用标记清理或整理算法。

3、算法实现

枚举根节点:可达性分析对执行时间很敏感,GC回收的对象都是被可达性分析过的,为了在整个分析期间执行系统的引用关系不能发生改变,不然分析结果准确性就无法得到保证。因此GC进行时必须停顿所有Java执行线程(Sun将这一事件称为"Stop The World"),即时在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

安全点:如何在GC时让所有的线程(不包括执行JNI调用线程)都“跑”到最近的安全生点上再停顿下来。有两中方式,抢先式中断和主动式中断。抢先式:是把所有的线程中断,不在安全点上的就恢复线程让它“跑”到安全点。主动式:设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志就挂起。


内存回收如何是由虚拟机所采用的GC收集器决定的,而通常虚拟机往往不止有一种GC收集器。

4、GC日志

每一种收集器的日志形式都是由它们自身的实现所决定,每个收集器的日志格式可以不一样。但是它们都维持一定的共性。例:

33.125:[GC [DefNew: 3324k -> 152k(3712K), 0.0025925 secs] 3324K -> 152K(11904K), 0.0030680 secs]

100.667:[Full GC [Tenured: 0k -> 200k(10240K), 0.0149142 secs] 4603K -> 210K(19456K ), [Perm : 2999K -> 2999K(21248K), 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

最前面的数字“33.125”,“100.667”:代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型。如果有“Full”,说明这次GC是发生了Stop-The-World的。如果是调用System.gc()方法所触发的收集,那么这里就显示“[Full GC”。

“[DefNew”、“ [Tenured”、"[Perm"表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的。

"3324k -> 152k(3712K)"意为 GC前该内存区域已使用容量 —> GC后该内存区域已使用容量(该内存区域总容量)。

0.0030680 secs :表示该内存区域所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如:"[Times : user - 0.01 sys-0.00.real - 0.02 secs]",这里面的user、sys和real与Linux的time命令输出时间含义一致,分别代表用户消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或多核的话,多线程操作会叠加这些CPU时间,所以看到user或sys时间超过real时间。

5、内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配,大多数情况下对象主要分配在新生代的Eden上,若没有足够空间会触发一次新生代的GC(Minor GC)。

大对象(长字符串,数组)需要大量连续内存空间的,会直接进入老年代。虚拟机提供了一个 -XX:PretenureSizeThreshold参数,大于这个设置值的对象直接在老年代分配。这样避免在Eden区及两个Survivor区之间发生大量的内存复制。

长期存活的对象将进入老年代,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,且被Survivor容纳的话,将被转移到Survivor空间中,且对象年龄设为1。对象在Survivor区中每"熬过"一次Minor GC,年龄就增加1岁,当它年龄增加到一定程度(默认15岁),就将会晋升到老年代。对象晋升老年代可以通过参数 -XX:MaxTenuringThreshold设置。

虚拟机并不是要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄不小于该年龄对象就可以直接进入老年代。

五、类的加载

详细解释一下类的加载过程

  • 加载:加载阶段虚拟机需要完成一下三件事情,1,通过一个类的全限定名来获取定义此类的二进制流。2、将这个字节流所代表的静态结构转化为方法区的运行时数据结构。3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 验证是连接阶段的第一步这一阶段的目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
  • 准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里内存分配仅包括类的变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  • 解析:虚拟机将常量池内的符号引用替换为直接引用过程。符号引用:是以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。直接引用:是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
  • 初始化:加载过程中的最后一步,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。

六、Java内存模型与线程

随着计算机的发展,多核处理器的计算机已被广泛应用。绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器需要若内存交互,如读取数据,存储运算结果等,然而计算机的存储设备与处理器的运算速度有几个数量级的差距。为了更充分利用计算机处理器的效能,现代计算机系统都不得不加入一层高速缓存来作为内存与处理器之间的缓冲。但是随之而来的一个新的问题,怎么能让缓存一致性。在多处理器中,每个处理器都有自己的高速缓存,而它们有共享一主内存。当多个处理器的运算任务都涉及同一块主内存区域是,将可能导致数据不一致。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。"内存模型"就可以理解为在特定的协议下,对特定的内存或高速缓存进行读写访问的过程抽象。而Java虚拟机也有自己的内存模型,且其内存访问操作与硬件的内存访问操作具有很高的可比性。

在这里插入图片描述

在这里插入图片描述

  • 关于volatile型变量的特殊规则

Java内存模型对volatile专门定义了一些特殊的访问规则。当一个变量定义为volatile之后,它将具备两个特性:一方面保证此变量对所有的线程的可见性(当一条线程修改了这个变量,新值对于其他线程来说是可以立即得知的)由于volatile只能保证可见性,在不符合以下以下两条规则的运算场景中,仍然需要通过加锁(使用synchronize或java.util.concurrent)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束。
    另一方面,在java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响单线程的执行,却会影响在多线程并发执行的正确性。而volatile关键字可以保证一定的"有序性"。关于volatile定义的特殊规则这里不在介绍。

以上来自于《深入理解Java虚拟机》一书。机械工业出版社,周志明 著。

猜你喜欢

转载自blog.csdn.net/qq_39742510/article/details/89333007
今日推荐