JVM自动内存管理

JVM自动内存管理

(1)Java内存区域

1)概述

虚拟机有着自动内存管理机制;

优势:不需要为每一个new操作去写配对的delete/free代码;

劣势:一旦内存泄漏和内存溢出,比了解虚拟机怎样使用内存时排查、修正问题问题异常艰难;

2)运行时数据区域

JVM所管理的内存将会包括以下几个运行时数据区域

  • 由所有线程共享的数据区:方法区、堆

  • 线程隔离的数据区:虚拟机栈、本地方法栈、程序计数器

程序计数器

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

字节码解释器工作时通过改变这个计数器的值来选取下一条要执行的字节码指令;

一个处理器(一个内核)为了在线程切换后恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器(程序计数器是线程私有的内存)

  • 线程正在执行的Java方法——>计数器记录该虚拟机字节码指令地址
  • 程序正在执行的本地方法(Native方法)——>计数器值为空(Undefined)
Java虚拟机栈

Java虚拟机栈线程私有,生命周期与线程相同;

Java虚拟机栈描述的是Java方法执行的线程内存模型:每个方法执行,Java虚拟机同步创建一个栈桢。一个方法被调用直到执行完毕——>对应栈帧在虚拟机入栈到出栈;

栈帧:用于存储局部变量表、操作数栈、动态链接、方法出口等信息;

局部变量表:存放基本数据类型、对象引用类型、returnAddress类型

基本数据类型:在局部变量表中的存储空间以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个,其余的数据类型只占用一个;

对象引用类型:不等同于对象本身,可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者与此对象相关的位置;

returnAddress类型:指向了一条字节码指令的地址;

本地方法栈

本地方法栈与虚拟机栈所发挥的作用相似,区别是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务

JAVA方法是由JAVA编写的,编译成字节码,存储在class文件中;

本地方法是由其它语言编写的,编译成和处理器相关的机器代码;

本地方法保存在动态链接库中,即.dll(windows系统)文件中,格式是各个平台专有的;

Java堆
  • 堆是虚拟机所管理的内存中最大的一块;
  • 堆是垃圾收集器管理的内存区域,一些资料也成为GC堆;
  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的是存放对象实例,几乎所有对象实例以及数组都在这里分配内存;

内存分配

所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区——>提升对象分配时的效率;

当堆中已经没有内存完成实力分配又无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常;

方法区

方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据;

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,对类型的卸载条件苛刻但是又是必要的。(难以令人满意)

运行时常量池

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量与符号引用(这部分内容将在类加载后存放到方法区的运行时常量池中),也把由符号引用翻译出来的直接引用也存储到运行时常量池中;

运行时常量池具有动态性:并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中;(特性使用:String类的intern()方法)

直接内存

直接内存不是JVM中定义的内存区域,分配不会受到Java堆大小的限制;

对外内存,受到本机总内存大小以及处理器寻址空间的限制;

直接内存的使用在一些场景中内购显著提高性能;

比如:NIO(New Input/Output)类引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作

3)HotSpot虚拟机对象

对象的创建

在语言的层面上,创建对象通常通过关键字new;

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

2.在类的加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可以完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来;

3.内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB,这一项工作也可以提前至TLAB分配时顺便进行。这部操作保证了对象的实例字段在Java代码中可以不赋值初始值就直接使用,使程序能访问到这些字段的数据类型对应的零值;

4.接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置;

5.在上面工作完成以后,从虚拟机的角度完成了对象的创建;从Java程序的角度来看,对象的创建才刚开始——构造函数即Class文件中的init()方法还没有执行,所有的字段都为默认的零值,对象需要的资源和信息还没有构造好。

6.按照一般对象的创建方式,执行new指令之后,会执行init()方法,然后对对象进行初始化,这样一个真正可用的对象才算完全构造出来;

对象空间分配方式
  • 指针碰撞

假如Java堆中内存是绝对规整的,所有被使用过的内存都放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配的内存就是把那个指针向空闲空间方向挪动一段与对象大小相等的内存距离,这种方式就被称为“指针碰撞”;

  • 空闲列表

如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”;

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有空间压缩整理的能力决定。

使用Serial、ParNew等带压缩整理过程的收集器——>采用的分配算法是指针碰撞

使用CMS这种基于清除算法的收集器——>采用较为复杂的空闲列表

线程安全问题

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的;

解决方案

第一种:堆分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;

第二种:把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要执行分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完,分配新的缓冲区时才需要同步锁定;(虚拟机是否使用TLAB可以通过-XX: +/-UseTLAB参数来决定)

对象的内存布局

对象在堆内存中的存储布局可以分为三个部分

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

对象头

虚拟机对象的对象头包括两类信息;

  • 第一类:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;
  • 第二类:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(并不是所有的虚拟机实现都必须在对象数据上保留类型指针);如果一个对象是Java数组,对象头中还必须有一块用于记录数组长度的数据;

实例数据

对象真正存储的有效信息,即程序代码里面所定义的各种类型的字段内容,无论是父类继承的还是子类中定义的;

对齐填充

并不是必须存在的,仅起到占位符的作用:由于虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍(任何对象的大小必须是8字节的整数倍),对象头部分已经设计成8字节的整数倍,当对象的实例数据没有对齐的话,就通过对对齐填充来补齐;

对象的访问定位

Java程序通过栈上的reference数据来操作堆上具体对象;

对象的访问方式由虚拟机实现而定,主流的访问方式有使用句柄直接指针两种;

句柄访问

使用句柄访问对象,Java堆中可能会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中就包含了对象实例数据与类型数据各自具体的地址信息;

好处:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集是对象被移动是普遍的)时只会改变句柄中的实例数据指针,而reference本身不需要被修改;

直接指针

使用直接指针访问独享,Java堆中毒想内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的是对象地址;

好处:速度更快,相比于句柄访问节省一次间接访问的开销;

4)OutOfMemoryError异常

内存溢出异常;

Java堆溢出

Java堆用于存储对象实例,在不断创建对象同时保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常;

Java堆内存的OutOfMemoryError异常是最常见的内存溢出异常情况,出现Java堆内存溢出时,异常堆栈信息"java.lang.OutOfMemoryError"会进一步提示"Java heap space";

处理方法:先分清楚出现了内存泄漏还是内存溢出;

  • 如果是内存泄漏,可以找出产生内存泄露代码的具体位置(通过工具查看泄露对象到GC Roots的引用链,找到泄露对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾回收器无法回收它们,根据泄露对象的类型信息以及它到GC Roots引用链的信息一般可以比较准确地定位到这些对象创建的位置,进而找到泄露代码的位置)
  • 如果是内存溢出,就应当检查Java虚拟机的堆参数(-Xmx和-Xms)设置,与机器内存对比,是否还有向上调整的空间。再从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存损耗;
栈溢出

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,所以设置本地方法栈大小的参数(-Xoss)没有实际效果,栈容量只能由-xss参数设置;

栈溢出的两种异常

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常;

HotSpot虚拟机不支持栈容量扩展,所以除非在线程申请内存时就因无法获得足够的内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常;

方法区和运行时常量池溢出

运行时常量池是方法区的一部分;

JDK6或者更早以前的HotSpot虚拟机中常量池分配在永久代中,当运行时常量池溢出时在OutOfMemoryError异常后面跟随的提示信息是"Perm Gen space";

JDK8以后,元空间代替永久代;

public static void main(String[] args) {
    String str1=new StringBuilder("hello").append("world").toString();
    System.out.println(str1.intern()==str1);//true
    /*
    1.字符串常量池在JDK7以后被移到堆中;
    2.JDK7以后String的intern()方法可以存放放于堆内的字符串对象的引用;
    3.所以上程序str1.intre()返回的是在字符串常量池中的堆中字符串"helloworld"的引用(地址值),与栈中str1的引用相同
     */
    String str2=new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern()==str2);//false
    /*
    1.字符串"java"是在加载sun.misc.Version这个类的时候进入常量池的,在常量池中本就存在(在str2声明之前);
    2.所以str2.intren()返回的是字符串常量池中"java"的引用(地址值),而str2是堆中"java"的引用;
     */
}
本机直接内存溢出

直接内存的容量大小通过-XX:MaxDirectMemorySize参数来指定,如果不指定则默认与Java堆最大值一致;

如果内存溢出以后产生的Dump文件很小,而程序中又直接或者间接使用了直接内存(典型的间接使用就是NIO),那就可以重点检查以下直接内存方面的原因;

(2)垃圾收集

1)概述

Java内存运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈这3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作,每一个栈帧中分配多少内存大体上可以认为在编译期可以确定,因此这几个区域地内存分配和回收都具备确定性,不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了;

Java堆和方法区这两个区域有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所欲要的内存可能也不一样,只有处于运行期间才会知道程序会创建哪些对象、创建多少对象,这部分内存的分配和回收是动态的;

2)判断存活算法

垃圾回收器在对堆进行回收前,需要确定对象是否“存活”;

Java虚拟机并不是通过引用计数算法来判断对象是否存活的;当前主流的商用程序语言(Java、C#、古老的Lisp)的内存管理子系统都是通过可达性分析算法来判定对象是否存活;

可达性算法

这个算法的基本思路就是通过一系列称为**“GC Roots”**的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者说从GC Roots不能到达这个对象时,则证明此对象是不能再被使用;

GC Roots

Java中固定可作为GC Roots的对象包括以下几类:

1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;

2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;

3.在方法区中常量引用的对象,譬如字符串常量池里的引用;

4.在本地方法栈中Native方法引用的对象;

5.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器;

6.所有被同步锁(synchronized关键字)持有的对象;

7.反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;

除了固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,(如分代收集和局部回收)共同构成完整的GC Roots集合;

引用

判断对象是否存活和引用离不开关系;

JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用软引用弱引用虚引用,这4种引用强度依次逐渐减弱;

强引用

强引用是最传统的引用定义,指在程序代码之中普遍存在的复制引用,即new对象这种引用关系;

只要强引用关系还存在,垃圾收集器永远不会回收被引用的对象;

软引用

软引用用来描述一些还有用,但并非必须的对象;

只被软引用关联着的对象,在进行一次垃圾回收后,系统将要发生内存溢出异常前,会把这些对象列入回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常;

JDK1.2后提供了SoftReference类来实现软引用;

弱引用

弱引用也是用来描述那些非必须的对象,但是它强度比软引用更弱一些;

被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象;

JDK1.2后提供了WeakReference类来实现弱引用;

虚引用

虚引用是最弱的一种引用关系;

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例,为一个对象设置虚引用关联的唯一目的是为了能在这个对象被收集器回收时能收到一个系统通知;

JDK1.2后提供了PhantomReference类来实现虚引用;

判断死亡方式

真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,拿他将会被第一次标记
  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。加入对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,那么虚拟机将这两种情况是为没有必要执行finalize()方法;
  • 如果这个对象被判定为有必要执行finalize()方法,那么对象将会被放置在一个名为F-Queue的队列中,并在稍后一条由虚拟机自动建立的、低优调度优先级的Finalizer线程去执行它们的finalize()方法,稍后收集器将对F-Queue中的对象进行第二次小规模的标记:如果对象在执行方法时重新与引用链上的对象关联,它将被移出即将被回收的集合,否则将被回收;
回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型

回收废弃常量与回收堆中的对象类似,当系统和虚拟机中没有任何对象引用这个常量,而这时发生内存回收,而垃圾收集器判断确有必要时,这个常量将会被系统清出常量池;常量池中其他类(接口)、方法、字段的符号引用也类似;

判断一个类型是否属于不再使用的类型需要同时满足下面三个条件:

1.该类的所有实例都已经被回收,也就是Java堆中不存在该类及任何派生子类的实例;

2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器场景,如OSGi、JSP的重加载等,否则通常很难达成;

3.该类对应的java.lang.Class对象没有任何地方被引用,无法再任何地方通过反射访问该类的方法;

3)垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(直接垃圾收集)和“追踪式垃圾收集”(间接垃圾收集)两大类,前者在JVM中不涉及;

分代收集理论

建立在两个分代假说之上:

  • **弱分代假说:**绝大多数对象都是朝生夕灭;
  • **强分代假说:**熬过越多次垃圾收集过程的独享就越难以消亡;

这两个分代假说奠定了常用的垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据年龄(对象熬过垃圾收集过程的次数)分配到不同的区域之中存储——如果一个区域中大多数对象都是朝生夕灭,那么集中在一起每次回收时就只需要关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低的代价回收大量空间;如果区域中都是难以消亡的对象,那么虚拟机就可以使用较低的频率来回收这个区域,减少开销和高效利用内存;

Java堆中划分出不同的区域后,垃圾收集器才可以每次只回收其中某个或某部分区域,才有了多种回收类型的划分,也才能够针对不同区域安排与里面存储对象存亡特征相匹配的垃圾收集算法(如标记-复制算法、标记-清除算法);

将分代收集理论具体放到商用虚拟机里,至少会把堆划分为新生代和老年代两个区域,在新生代中每次垃圾回收时都发现有大批的对象死去,而每次回收后存活的少量对象将会逐步晋升到老年代中存放;

  • 部分收集(Partial GC):指目标不是完整收集整个堆的垃圾收集;
  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾回收;
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集;
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集(G1收集器);
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集;

存在问题:假如要进行一次只局限与新生代区域内的收集,但新生代中的对象完全有可能被老年代所引用,为了找到区域中存活的对象,不得不在固定GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也一样;这时候就会为内存回收带来很大的性能负担;

为了解决上述问题引入了第三条假说——跨代引用假说:跨代引用相对于同代引用来说仅占极少数;

这个假说指出:存在互相引用关系的两个对象,是倾向于同时生存或者同时消亡的;如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代之中,跨代引用也就消除了;

标记-清除算法

最早、最基础的垃圾收集算法,后续的收集算法大多都是以它为基础对其缺点进行改进的到的;

算法分为标记清除两个阶段:首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象,或者也可以标记存活的对象,统一回收未被标记的对象。标记的过程就是判断对象是否是垃圾的过程。

主要缺点有两个:

执行效率不稳定,如果堆中包含大量对象且大部分需要被回收,这时候需要进行大量的标记和清除动作,导致两个阶段的执行效率随对象数量增长而降低;

内存空间的碎片化问题,标记、清除后会产生大量不连续的内存碎片,空间碎片大多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存,从而不得不提前触发零以此垃圾收集动作;

标记-复制算法

标记-复制算法常被成为复制算法;

半区复制算法:将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把这一块中已使用过的内存空间(包括存活的和可回收的)清空作为未使用的一块区域;

存在问题:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销;并且这种算法的代价是将内存缩小为原来的一半,空间浪费严重;

Appel式回收:半区复制算法的优化,HotSpot虚拟机的Serial、ParNew等新生代收集器均采用这种策略。Appel式回收的做法是把新生代分为一块较大的Eden空间两块较小的survivor空间,每次分配内存只使用Eden和其中一块Survivor空间,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间中,然后清理掉Eden和已经用过的那块Survivor空间。

存在问题:Eden和Survivor的默认容量比例是8:1,但是不能百分之百保证每次复制的存活对象所占用的内存不会大于一块Survivor的空间;

解决方案:依赖其他内存区域(大多数是老年代)进行分配担保:当另外一块Survivor空间没有足够空间存放上一次新生代复制下来的存货对象,这些对象便通过分配担保机制直接进入老年代的区域;

标记-整理算法

老年代中一般不能直接选用标记-复制算法;

针对老年代对象的存亡特征有标记-整理算法:其中的标记过程与“标记-清除算法”相同,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

标记-整理算法与标记-清除算法的本质差异在于前者为非移动式的回收算法,后者是移动式;

存在问题:移动存活对象特别是老年代这种每次回收留有大量对象存活的区域,将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行;(标记-清除算法也会停顿,时间较短)

从整个程序的吞吐量(实质是用户程序+收集器的效率总和)来看,移动对象会更划算——虽然不移动对象会使收集器的效率提升一些,但是内存分配和访问相比垃圾收集的频率高得多,对于这部分效率的提升更重要;

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的;(当CMS收集器面临空间碎片过多时采用标记-整理算法收集一次,以获得规整的空间)

4)算法细节实现

根节点枚举

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举;

安全点

HotSpot没有为每条指令都生成OopMap,只是在“特定的位置”记录这些信息,这些位置被称为“安全点”;

安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此在这个区域之中任意地方开始垃圾收集都是安全的。

记忆集与卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构;

卡表时一种实现记忆集的方式,最简单的形式是只是一个字节数组,而HotSpot虚拟机就是这种形式;

写屏障

HotSpot虚拟机里是通过写屏障技术维护卡表状态的;

并发时的可达性

当用户线程与收集器是并发工作,用户线程修改引用关系的同时垃圾收集器在判定对象的可达性,这时候可能导致原来死亡的对象被认为是存活或者原本是存活的对象被标记成死亡,因此会出现“并发对象消失”问题;

5)经典垃圾收集器

Serial收集器

最基础、历史最悠久的收集器;

单线程工作收集器,只会使用一个处理器或者一条线程来进行垃圾收集,在进行垃圾收集时必须暂停所有工作线程直到结束垃圾收集;

能与CMS收集器配合工作;

ParNew收集器

实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余行为包括控制参数、收集算法、对象分配原则、回收策略等都与Serial收集器完全相同;

能与CMS收集器配合工作;

并行与并发

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态;

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定的影响;

吞吐量

所谓吞吐量就是处理器用于运行用户代码的时间处理器总消耗时间比值,处理器总消耗时间为运行用户代码的时间加上运行垃圾收集的时间;

吞吐量=运行用户代码的时间/(运行用户代码的时间+运行垃圾收集的时间)

Parallel Scavenge收集器

Parallel Scavenge收集器是一款新生代的收集器,同样基于标记-复制算法实现,是能够并行收集的多线程收集器;

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器地目标则是达到一个可控的吞吐量

Parallel Scavenge收集器不能与CMS配合工作;

由于与吞吐量关系密切,Parallel Scavenge收集器也被称为”吞吐量优先收集器“;

自适应调节策略:虚拟机会根据当前系统地运行状况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量;

Parallel Scavenge收集器的自适应调节策略是区别于ParNew收集器的一个重要特性;

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法

这个收集器的主要意义是攻客户端模式下的HotSpot虚拟机使用。如果在客户端模式下,它可能有两种用途:

1.在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用;

2.作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用;

注:Parallel Scavenge收集器架构中本身由PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但前后者的实现几乎相同;

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现;

这个收集器是直到JDK6时才开始提供的,在此之前Parallel Scavenge收集器只能搭配Serial Old或者PS MarkSweep,在性能上被拖累,因此未必能在整体上获得吞吐量最大化的效果;

直到Parallel Old收集器出现,Parallel Scavenge收集器才有了搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合都可以优先考虑Parallel Scavenge+Parallel Old这对组合;

CMS收集器

CMS收集器是一种获得最短回收停顿时间为目标的收集器,在关注服务响应速度的应用服务(比如互联网网站)通常会希望系统停顿时间尽可能短,CMS收集器就非常符合这类应用的要求;

CMS收集器是基于标记-清除算法实现的,它的运作过程相对复杂,分为四个步骤:

  • 1.初始标记
  • 2.并发标记
  • 3.重新标记
  • 4.并发清除

其中,初始标记、重新标记这两个步骤会导致用户线程停顿;而并发标记和并发清除两个步骤不用停顿用户线程

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
  • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图,这个过程消耗时间较长但是并不需要停顿用户线程,用户线程可以与垃圾收集线程一起并发运行;
  • 重新标记:修正并发标记期间因为用户程序继续运作而导致标记发生变动的那一部分对象的标记记录,这个过程的停顿时间通常会比初始标记阶段稍微长一些,但是远比并发标记时间短;
  • 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程一起并发运行的;

从整体上来说,停顿用户线程的时间相对较短,所以CMS收集器的内存回收过程是与用户线程一起并发执行的;

CMS优点:并发收集、低停顿;

CMS缺点

1.对处理器资源非常敏感,在并发阶段虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说是处理器的计算能力)而导致应用程序变慢,降低总吞吐量;当处理器核心数在四个以下时,CMS对用户程序的影响就可能变得很大,如果在本来负载就很高的情况下,还要分出一部分运算能力去执行收集器线程,就可能使用户程序的执行速度大幅下降;

2.无法处理浮动垃圾:浮动垃圾是伴随着标记过程出现的垃圾,在标记过程结束以后CMS只能留待下一次收集这一部分的垃圾;

3.CMS是基于标记-清除算法实现的,收集结束以后会留有大量的空间碎片;

Garbage First收集器

Garbage First收集器(简称G1)开创了收集器面向局部收集的设计思路和**基于Region(独立区域)**的内存分布形式,是一款主要面向服务端应用的垃圾收集器;

Mixed GC垃圾回收模式:G1收集器可以向堆内存任何部分组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪一个分代,而是哪一块内存中存放的垃圾数量多,回收效益最大;

Region

G1开创的基于Region的堆内存布局是能够实现Mixed GC的关键,虽然G1也是遵循分代收集理论设计的,但其堆内存的布局与其他垃圾收集器有明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要扮演新生代的Eden空间、Survice空间或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新建的对象还是已经存活了一段时间的老对象都能获得好的收集效果;

Region是单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍;

G1中的新生代和老年代不再是固定的,而是一系列区域的动态集合;

这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限时间内获取尽可能高的收集效率;

将堆分成多个独立Region后,Region内存在的跨Region引用对象如何解决?

每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内;(由于Region的数量较多,G1收集器要比其他传统垃圾收集器有着更高的内存占用负担)

在并发标记阶段如何保证如何保证收集线程与用户线程互不干扰地进行?

G1为每一个Region设计了两个名为TAMS地指针,把Region中的一部分空间划分出来用于并发回收过程中地新对象分配,并发回收时新分配地对象地址都必须要在这两个指针位置以上;G1收集器默认在这个地址以上的对象是被隐式标记过的,默认存活,不会纳入回收范围,如果内存回收速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程;

G1收集器运作过程
  • 初始标记:仅仅只是标记以下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时能正确地在可用的Region中分配新对象。这个过程需要停顿线程,但是耗时很短,而且是借用进行Minor GC的时候同步完成的,所以在这个阶段实际没有额外的停顿
  • 并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找到要回收的对象,这阶段消耗时间较长,但可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理STAB记录下的在并发时有引用改动的对象;
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的STAB记录;
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,在清理掉整个旧的Region的全部空间,这里的操作涉及存活对象的移动,**是必须暂停用户线程的,**有多条收集器线程并行完成;

除了并发标记,其余阶段都是需要暂停用户线程的;

G1收集器与CMS收集器比较

G1

优势:可以指定最大停顿时间、分成Region的内存布局、按收益动态确定回收集、整体基于标记-整理算法而局部基于标记-复制算法、不会产生内存空间碎片、垃圾收集后能够提供规整的可用内存;

劣势:G1为垃圾收集产生的内存占用和程序运行时的额外执行负载都比CMS高、Region中的记忆集占用内存较高而CMS的卡表较简单;

垃圾收集器指标

衡量垃圾收集器的三项最重要的指标是:内存占用吞吐量延迟

低延迟垃圾收集器

在衡量垃圾收集器的三项指标里,延迟的重要性日益凸显;硬件规格提升,内存扩大,吞吐量会增加,但是延迟会更久;

低延迟垃圾收集器(Shenandoah和ZGC)几乎整个工作过程都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿时间基本固定,与堆容量、对象数量无关;

Shenandoah收集器
ZGC收集器
Epsilon收集器
虚拟机及垃圾收集器日志
垃圾收集器参数

(3)性能监控与故障处理

1)概述

依据数据:异常堆栈、虚拟机运行日志、垃圾收集器日志、程序快照、堆存储快照等

2)基础故障处理工具

虚拟机进程状态工具

jps:列出正在运行的虚拟机进程,并显示虚拟机执行主类的名称以及这些进程的本地虚拟机的唯一ID;

jps命令格式

jps [options] [hostid]
虚拟机统计信息监视工具

jstat:用于监视虚拟机各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据;

jstat命令格式

jstat [option vmid [interval[s|ms]]]
Java配置信息工具

jinfo:实时查看和调整虚拟机各项参数;

jinfo命令格式

jinfo [option] pid
Java内存映像工具

jmap:用于生成堆存储快照,还可以查询finalize()执行队列、Java堆和方法区的详细信息如空间使用率、当前收集器等;

jmap命令格式

jmap [option] vmid
虚拟机堆存储快照分析工具

JDK提供jhat与就map搭配使用来分析jmap生成的堆存储快照;

3)可视化故障处理工具

这类工具主要包括JConsole、JHSDB、VisualVM、JMC四个;

(4)调优案例分析与实战

1)概述

虚拟机的处理与调优主要面向各类服务端应用;

2)案例分析

大内存硬件上的程序部署策略

存在问题:过大的堆内存进行回收时带来长时间的用户线程停顿;

解决问题

目前单体应用在较大内存的硬件上主要的部署方式有两种:

1.通过一个单独的Java虚拟机实例来管理Java堆内存;

2.同属使用若干个Java虚拟机,建立逻辑集群来利用硬件资源;

针对上述问题,最后方案是建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中固定堆为1.5GB),占用了10GB内存,另外建立一个Apache服务作为前端均衡代理作为访问门户;考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度较低,因此改为CMS收集器进行垃圾回收;

集群间同步导致的内存溢出
堆外内存导致的溢出错误

服务器中的内存划分了大部分给堆,而直接内存耗用的内存占小部分,虚拟机虽然会对直接内存进行回收,但是直接内存不能像新生代、老年代那样,发现空间不足了就主动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,顺便帮它清除掉内存的废弃对象,否则就不得不等到捕获异常时在进行System.gc()操作来触发垃圾收集;

除了堆和方法区以外,直接内存、线程堆栈、Socket缓存区、JNI代码、虚拟机和垃圾收集器也会占用较多的内存;

外部命令导致系统缓慢

案例问题:在一个校园系统中,每个用户请求的处理都需要执行一个外部的Shell脚本来获得系统的一些信息,执行这个脚本是通过Java的Runtime.gettime.getRuntime().exec()方法来调用的,但是它在JVM中是非常消耗资源的,虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程,在用这个新的进程去执行外部命令,最后在退出这个进程,频繁地执行这个操作时,系统的消耗非常大,内存负担也很重;

服务器虚拟机进程崩溃

案例问题:系统用户过多,待办事项变化很快,为了不被OA系统速度拖累,采用异步地方式调用Web服务,由于两边服务速度完全不对等,时间越长就积累了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃;

解决方案:通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列后,系统恢复正常;

不恰当数据结构导致内存占用过大
由Windows虚拟内存导致的长时间停顿
由安全点导致的长时间停顿

3)运行速度调优

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
JVM的内存模型是指Java虚拟机在运行时对内存的组织和管理方式。它定义了JVM内存的不同区域以及各个区域的作用和特点。 JVM的内存模型可以分为以下几个部分: 1. 程序计数器(Program Counter Register):每个线程都有自己的程序计数器,用于记录当前线程执行的字节码指令的地址。 2. Java虚拟机栈(Java Virtual Machine Stacks):每个线程在执行Java方法时会创建一个对应的栈帧,栈帧用于存储方法的局部变量、操作数栈、方法返回值等信息。 3. 堆(Heap):堆是JVM中最大的一块内存区域,被所有线程共享。它用于存储对象实例和数组。堆内存由垃圾回收器自动管理,负责对象的分配和释放。 4. 方法区(Method Area):方法区用于存储已加载类的信息、静态变量、常量、即时编译器编译后的代码等。在JDK 8及以后的版本中,方法区被元空间(Metaspace)所取代。 5. 运行时常量池(Runtime Constant Pool):每个类或接口在编译后都会生成一个运行时常量池,用于存放编译器生成的字面量和符号引用。 6. 本地方法栈(Native Method Stacks):本地方法栈用于执行本地方法(Native Method)的栈。 7. 直接内存(Direct Memory):直接内存不是JVM管理的堆内存,而是通过操作系统本地IO直接分配的内存。一般在使用NIO(New Input/Output)时会使用到直接内存。 这些内存区域共同组成了JVM的内存模型,对于Java程序的运行和性能有着重要的影响。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值