深入理解Java虚拟机学习笔记

 2019-12-22 10:50  阅读(607)
文章分类:JVM

一:java内存区域

堆内存区域概图及相关参数对照图

2019120001356\_1.png

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

主要包括以下几个内存区域:

2019120001356\_2.png

1:程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。

在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要这个计数器来完成。

(每一个线程都有一个独立的计数器,各条线程之间计数器互不影响,独立存储)

2.Java虚拟机栈(Java Virtual Machine Stacks)

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

(经常有人把java内存区分为堆内存和栈内存,所指的栈内存就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分)

Java栈是FILO结构,只支持入栈和出栈两种操作,在栈中主要保存的内容为栈帧。每一次函数调用,都有一个对应的栈帧被压入栈,每一个函数调用结束,都有一个栈帧出栈。栈帧保存着当前函数的局部变量、方法参数、中间运算结果等数据。

局部变量表部分 存放了编译期可知的八种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)

注意:这个区域规定了两种异常状况:

* stackOverflowError 线程请求的栈深度大于虚拟机所允许的深度(一般情况下,无限递归调用即可造成这种状况)

* outOfMemoryError 虚拟机栈动态扩展时无法申请到足够的内存(方法内对象占内存过大且无法释放)

3.本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈所发挥的作用非常相似

区别:1)虚拟机栈为虚拟机执行Java方法(字节码)服务

2)本地方法栈则为虚拟机所使用到的native方法服务

4.Java堆(Heap)

Java堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

5.方法区(Method Area)

方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据

方法区除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集

6.运行时常量池(Runtime Constant Pool)

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

二:对象的创建过程

  1. 虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化过。如果没有,则先执行相应的类加载过程。

(判断类是否被编译过,是否有相应的class文件)

  1. 在类加载检查通过后,虚拟机将会对新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配内存空间的任务等同于把一块确定大小的内存从堆中划分出来。
  2. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用。

三:对象的访问定位

建立对象是为了使用对象,我们的java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也取决于虚拟机实现而定的。

目前主流访问方式有使用句柄和直接指针两种。

1.句柄方式

Java堆将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

2.指针访问

Reference中直接存储的就是对象地址

各自优劣势:

1)使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只改变句柄中的实例数据指针,而reference本身不需要改变。

2)使用指针访问最大好处就是速度更快,它节省了一次指针定位的时间开销。

注意:如何判定一个对象可以被GC回收?

通过可达型分析,当一个对象到GC Roots没有任何引用链相连时,可判定此对象不可用

可作为GC Root的对象有:

* 虚拟机栈中引用的对象

* 方法区中类静态属性引用的对象

* 方法区中常量引用的对象

* 本地方法栈中JNI引用的对象

四:引用的分类

Java中引用的定义很传统:如果reference类型的数据中存储的数据代表另外一块内存的起始地址,就称这块内存代表着一个引用。

JDK1.2之后,java对引用的概念进行扩充,将引用分为 强引用、软引用、弱引用、虚引用

1)强引用:代码中普遍存在的,类似 Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

2)软引用用来描述一些还有用但是并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。

3)弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

4)虚引用也称为幽灵引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过一个虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器收集时收到一个系统通知。

五:垃圾收集算法

1)标记–清除算法

最基础的算法就是 标记-清除算法,该算法包括 标记 和 清除两个阶段。

标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。

不足:效率问题(标记和清除两个过程的效率都不高)

空间问题(标记清除后会产生大量的不连续的内存碎片,空间碎片过多可能会导致程序以后再分配大的对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作)

2)复制算法

为了解决效率问题,一种称为 复制 的收集算法出现了,它将可用内存按照容量划分为大小相等的两块,每次只是用其中的一块,当这块内存用完后,就将还存活的对象复制到另一块,然后把该已使用的内存块空间 一次清理掉

现在的商业虚拟机都采用这种收集算法来回收新生代。

IBM研究表明:新生代中的对象98%是 朝生夕死的,所以并不需要按照1:1来分配,而是将内存分配为一块较大的Eden空间和两块较小的survivor空间, 每次使用Eden和其中一块survivor。

当回收时,将Eden和survivor中还存活的对象一次性复制到另一块survivor空间商,最后清理掉Eden和刚才使用的survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用的内存空间为整个新生代容量的90%,只有10%内存会被浪费。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保

3)标记–整理算法

复制收集算法在对象存活率较高时就要进行更多的复制操作,效率就会变得更低。一般在老年代不能直接选用这种算法。

根据老年代的特点,可以采用 标记–整理 算法,标记过程仍与 标记–清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向另一端移动,然后直接清理掉边界以外的内存。

4)分代收集算法

当前的商业虚拟机都采用 分代收集算法。

根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这个可以根据各个年代的特点选择最适合的收集算法。

在新生代,每次垃圾收集时都会有大量对象死去,只有少量存活,那就选用复制算法;

在老年代,对象存活率高,没有额外的空间对它进行担保分配,就必须使用 标记–清理 或者 标记–整理 算法来进行回收

六:垃圾回收器

2019120001356\_3.png

如上图所示,垃圾回收算法一共有7个,3个属于年轻代、三个属于年老代,G1属于横跨年轻代和年老代的算法。JVM会从年轻代和年老代各选出一个算法进行组合,连线表示哪些算法可以组合使用

1、Serial(年轻代)

    1. 年轻代收集器,可以和Serial Old、CMS组合使用
    2. 采用复制算法
    3. 使用单线程进行垃圾回收,回收时会导致Stop The World,用户进程停止
    4. client模式年轻代默认算法
    5. GC日志关键字:DefNew(Default New Generation)
    6. 图示(Serial+Serial Old)2019120001356\_4.png

2、ParNew(年轻代)

    1. 新生代收集器,可以和Serial Old、CMS组合使用
    2. 采用复制算法
    3. 使用多线程进行垃圾回收,回收时会导致Stop The World,其它策略和Serial一样
    4. server模式年轻代默认算法
    5. 使用-XX:ParallelGCthreads参数来限制垃圾回收的线程数
    6. GC日志关键字:ParNew(Parallel New Generation)
    7. 图示(ParNew + Serail Old)2019120001356\_5.png

3、Paralle Scavenge(年轻代)

    1. 新生代收集器,可以和Serial Old、Parallel组合使用,不能和CMS组合使用

    2. 采用复制算法

    3. 使用多线程进行垃圾回收,回收时会导致Stop The World

    4. 关注系统吞吐量

      1. -XX:MaxGCPauseMillis:设置大于0的毫秒数,收集器尽可能在该时间内完成垃圾回收
      2. -XX:GCTimeRatio:大于0小于100的整数,即垃圾回收时间占总时间的比率,设置越小则希望垃圾回收所占时间越小,CPU能花更多的时间进行系统操作,提高吞吐量
      3. -XX:UseAdaptiveSizePolicy:参数开关,启动后系统动态自适应调节各参数,如-Xmn、-XX:SurvivorRatio等参数,这是和ParNew收集器重要的区别
      4. GC日志关键字:PSYoungGen

4、Serial Old(年老代)

    1. 年老代收集器,可以和所有的年轻代收集器组合使用(Serial收集器的年老代版本)
    2. 采用 ”标记-整理“算法,会对垃圾回收导致的内存碎片进行整理
    3. 使用单线程进行垃圾回收,回收时会导致Stop The World,用户进程停止
    4. GC日志关键字:Tenured
    5. 图示(Serial+Serial Old)2019120001356\_6.png

5、Parallel Old(年老代)

    1. 年老代收集器,只能和Parallel Scavenge组合使用(Parallel Scavenge收集器的年老代版本)

    2. 采用 ”标记-整理“算法,会对垃圾回收导致的内存碎片进行整理

    3. 关注吞吐量的系统可以将Parallel Scavenge+Parallel Old组合使用

    4. GC日志关键字:ParOldGen

    5. 图示(Parallel Scavenge+Parallel Old)

      2019120001356\_7.png

6、CMS(Concurrent Mark Sweep年老代)

    1. 年老代收集器,可以和Serial、ParNew组合使用
    2. 采用 ”标记-清除“算法,可以通过设置参数在垃圾回收时进行内存碎片的整理
      1、UserCMSCompactAtFullCollection:默认开启,FullGC时进行内存碎片整理,整理时用户进程需停止,即发生Stop The World
      2、CMSFullGCsBeforeCompaction:设置执行多少次不压缩的Full GC后,执行一个带压缩的(默认为0,表示每次进入Full GC时都进行碎片整理)
    3. CMS是并发算法,表示垃圾回收和用户进行同时进行,但是不是所有阶段都同时进行,在初始标记、重新标记阶段还是需要Stop the World。CMS垃圾回收分这四个阶段
      1、初始标记(CMS Initial mark) Stop the World 仅仅标记一下GC Roots能直接关联到的对象,速度快
      2、并发标记(CMS concurrent mark) 进行GC Roots Tracing,时间长,不发生用户进程停顿
      3、重新标记(CMS remark) Stop the World 修正并发标记期间因用户程序继续运行导致标记变动的那一部分对象的标记记录,停顿时间较长,但远比并发标记时间短
      4、并发清除(CMS concurrent sweep) 清除的同时用户进程会导致新的垃圾,时间长,不发生用户进程停顿
    4. 适合于对响应时间要求高的系统
    5. GC日志关键字:CMS-initial-mark、CMS-concurrent-mark-start、CMS-concurrent-mark、CMS-concurrent-preclean-start、CMS-concurrent-preclean、CMS-concurrent-sweep、CMS-concurrent-reset等等
    6. 缺点
      1、对CPU资源非常敏感
      2、CMS收集器无法处理浮动垃圾,即清除时用户进程同时产生的垃圾,只能等到下次GC时回收
      3、因为是使用“标记-清除”算法,所以会产生大量碎片
    7. 图示2019120001356\_8.png

7、G1

    1. G1收集器由于没有使用过,所以从网上找了一些教程供大家了解

      1. 并行与并发
      2. 分代收集
      3. 空间整合
      4. 可预测的停顿

8、各垃圾收集参数设置

2019120001356\_9.png2019120001356\_10.png

七:内存分配与回收策略

堆内存概图如下:

2019120001356\_11.png

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的eden区,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用 的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置

1)对象优先在Eden区上分配

大多数情况下,对象在新生代Eden区上分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC

Minor GC 和 Full GC的区别:

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为java对象大多数都具有 朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随着至少一次的Minor GC。Major GC速度一般比Minor GC慢10倍以上。

2)大对象直接进入老年区

所谓的大对象 是指,需要大量连续内存的java对象。

大对象对虚拟机来说是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前出发了垃圾收集以获取足够空间来安置它们。

虚拟机提供一个 -XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。

这样做的目的是避免在Eden区 和 两个Survivor区之间发生大量的内存复制。

3)长期存活的对象进入老年区

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。

如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor区,并且对象年龄设为1。对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。

八:虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析、初始化,并最终形成可被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

与那些在编译时需要进行连接工作的语言不同,在java语言里面,类型的加载、连接和初始化都是在程序运行期间完成的。

这种策略虽然增加了类加载时的内存开销,但是为java应用程序提供了高度的灵活性,java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类从被加载到虚拟机内存,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7个阶段。其中 验证 准备 解析3个部分统称为连接。

什么情况下需要开始类加载过程的第一个阶段,加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是在初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行初始化

1)遇到new 、getstatic、putstatic、invokestatic这四条字节码指令时,如果类没有进行初始化,则需要先触发其初始化。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

3)当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类。

5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类并没有进行初始化,则需要先触发其初始化。

九:类加载的过程

1.加载

在加载阶段,虚拟机需要完成3件事情:

1)通过一个类的权限定名来获取定义此类的二进制字节流

2)将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后再内存中实例化一个java.lang.Class类的对象(并没有明确规定是在java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中这些类型数据的外部接口。

2.验证

验证是连接阶段的第一步,这一阶段主要是为了保证Class文件中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了java虚拟机是否能够承受恶意代码的攻击,从执行性能上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。

从整体上看,验证阶段大致会完成下面四个阶段的校验动作:

文件格式验证、元数据验证、字节码验证、符号引用验证

1)文件格式验证

验证字节流是否符合Class文件格式的规范,并且能够被当前版本的虚拟机处理

2)元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

3)字节码验证

主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

4)符号引用验证

该验证发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三阶段–解析阶段发生。

3.准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。(注意是类变量而不是实例变量,实例变量将会在对象实例化时随着对象一起分配在堆内存中)

4.解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。

5.初始化

类的初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。

十:Java内存模型与线程

1.多线程设计概述及高速缓冲一致性

1)为何设计成多线程执行任务?

由于计算机的计算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘IO,网络通信和数据库访问上。所以设计成多任务执行,更好的应用CPU

2)如何处理硬件的效率问题?

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,现代计算机加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲。将运算需要的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存中,这样处理器就无需等待缓慢的内存读写了

3)如何处理缓存一致性问题?

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。

解决这种数据不一致问题,需要各个处理器在访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

2.Java内存模型

Java虚拟机规范试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台下都能达到一致的内存访问效果。

JMM的主要目标是定义程序中各个变量(包括实例字段、静态字段等但不包括局部变量和方法参数)的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

1)主内存和工作内存

* 所有变量存储在主内存

* 每条线程都自己的工作内存(保存了该线程使用到的主内存副本拷贝)

* 线程对变量的操作在工作内存中进行,不能直接读写主内存中的变量

* 不同线程之间无法访问对方工作内存中的变量

2)主内存和工作内存的交互

名称 作用变量 作用
名称 作用变量 作用
lock 主内存变量 将变量标识为一条线程独占状态
unlock 主内存变量 将变量从锁定状态释放
read 主内存变量 将变量值从主内存传输到线程工作内存
load 工作内存 将从主内存read到的变量值放入工作内存变量副本
use 工作内存 将工作内存的变量值传递给执行引擎
assign 工作内存 将一个从执行引擎中接收到的值赋给工作内存的变量
store 工作内存 将工作内存中的值传递给主内存
write 主内存变量 把store操作从工作内存中得到的值放入主内存变量中
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 深入理解Java虚拟机学习笔记

相关推荐