1.谈谈java的运行时数据库

java虚拟机在执行java程序的过程中会把它所管理的内存划分成若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有的区域依赖用户线程的启动和结束而创立和销毁。
image.png
image.png
(1)程序计数器
程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在java虚拟机器的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来获取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于java虚拟机的多线程是通过线程轮流切换、分配cpu时间片的方式来实现的,所以在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要由一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为”线程私有”的内存。
如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,这个计数器值则为空。这块区域是唯一一个在《java虚拟机规范》中没有规定任何oom情况的区域。
(2)java虚拟机栈
与程序计数器一样,java虚拟机栈也是线程私有的,它的生命周期与线程相同。
栈是JVM运行时数据区的核心部分,除了一些Native方法调用是通过本地方法栈实现的,其他所有的java方法调用都是通过栈来实现的,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。每个栈帧都拥有:局部变量表、操作数栈、动态链接、方法返回地址。
image.png
局部变量表:主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可以是指针对象起始地址的引用指针,或者是指向一个代表对象的句柄)。
操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中的临时变量也会存放在操作数栈中。
动态链接:主要服务一个方法需要调用其他方法的场景。在java源文件被翻译成字节码文件时,所有的变量和方法引用都作为符号引用保存在Class文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
方法返回地址:java方法有两种返回方式,一种是return语句正常返回,一种是抛出异常。不管哪种返回方式都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
在《java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程申请的栈深度大于虚拟机所允许的栈深度就会抛出stackOverFlow的异常;如果java虚拟机栈容量可以动态扩展,当栈扩展至无法申请足够的内存容量时就会抛出oom异常。
StackOverFlow的异常比较容易实现,当递归调用没有出口返回的时候就会抛出这类异常。
oom的异常可以通过循环创建线程,导致内存溢出异常。
(3)本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,主要区别是虚拟机栈为虚拟机执行java方法服务,而本地方法栈为虚拟机栈执行本地方法服务。与虚拟机栈一样,如果本地方法栈申请的栈深度达到虚拟机栈所允许的最大栈深度阈值就抛出stackOverFlow的异常;如果虚拟机栈容量可以动态扩展,当栈扩展至无法申请足够的内存空间时就会抛出oom异常。
(4)java堆
对于java应用程序来说,java堆是虚拟机所管理的内存中最大的一部分。java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。这块内存区域的唯一目的就是存放对象实例。从内存回收的角度看,由于现代垃圾回收器大部分都是基于分代收集理论设计的,所以java堆中经常出现”新生代”、”老年代”、”伊甸园区”、“幸存区from”、“幸存区to”等名词,这些区域划分是一部分垃圾收集器的共同特性或设计风格。java堆既可以实现成固定大小的,也可以是动态扩展的,不过当前主流的java虚拟机都是按照可扩展来实现的(通过参数-Xms和-Xmx设定)。如果java堆中没有内存能够进行实例分配,并且堆也无法再扩展的时候,java虚拟机就会抛出oom异常。
(5)方法区
方法区和java堆一样,是各个线程共享的内存区域,它用于存放被虚拟机器加载的类型信息、常量、静态变量以及即时编译器编译后的代码缓存等。 在jdk8以前,hotspot虚拟机使用永久代来实现方法区,方法区中存储被虚拟机加载的类型信息、常量、静态变量以及即时编译器编译后的代码缓存等。这种设计主要是为了让hotsopt的垃圾回收器能够像管理堆内存那样去管理方法区中的这部分内存,省去专门为方法区编写内存管理代码的工作。但这种设计导致java应用更容易遇到内存溢出的问题。在jdk6的时候hotspot开发团队就有逐渐放弃永久代,改用本地内存来实现方法区的计划了。到了jdk7,原本存储在永久代中的字符串常量池、静态变量等移至java堆中。到jdk8彻底废弃永久代的概念,改用在本地内存实现的元空间来代替,把jdk7永久代还剩余的内容(主要是类型信息)全部移至元空间中。
如果方法区无法满足新的内存分配的需求时,就会抛出oom异常。
(6)运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、接口、方法等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池,把符号引用翻译出来的直接引用存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特性就是具备动态性,java语言并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中,比如String类的intern()方法。
既然运行时常量池也是方法区中的一部分,自然也收到方法区内存的限制,当常量池无法再申请到内存时就会抛出oom异常。
(7)直接内存
直接内存并不是虚拟机运行时数据库的一部分,但是这部分内存也被频繁地使用,而且也可能导致oom异常。在jdk1.4新加入了nio类,引入了一种基于通道(Channel)和缓冲区(Buffer)的io方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样在一些场景下能显著提升性能,因为避免了在内核缓存区与应用程序缓冲区之间来回复制数据。
本机直接内存不会受到java堆大小的限制,但是既然是内存,则肯定还是会受到本机总内存(包括物理内存、Swap分区或者分页文件)以及处理器寻址空间的限制。

2.谈谈java对象的创建

(1)类加载检查:
当虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
(2)分配内存:
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从java堆中划分出来。分配方式有”指针碰撞”和”空闲列表”两种,选择哪种分配方式由jav堆是否规整决定,而java堆是否规整又由所采用的的垃圾收集器是否带有整理功能决定。
指针碰撞:
【适用场合】:堆内存规整(没有内存碎片)的情况下。
【原理】:用过的内存全部整合到一遍,没有用过的内存放在另一遍,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
【适用该分配方式的GC收集器】:Serial、ParNew
空闲列表:
【适用场合】:堆内存不规整的情况下。
【原理】:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录。
【适用该分配方式的GC收集器】:CMS
内存分配并发问题:
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能会会先正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。虚拟机采用cas+失败重试的方式保证更新操作的原子性。
(3)初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证对象的实例属性在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
(4)设置对象头
初始化零值完成后,虚拟机要对对象进行必要的设置,比如这个对象属于哪个类的实例、如何才能找到类的元数据信息、哈希码、GC分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,比如是否启用偏向锁等,对象头会有不同的设置方式。
(5)执行init方法
从虚拟机的视角来看,一个新的对象已经产生了,但从java程序的视角来看,对象创建才刚开始,因为方法还没有执行,所有的字段都还未零。所以一般来说,执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生。
【对象的访问定位】
创建对象自然是为了后续使用该对象,我们java程序会通过栈上的reference数据来操作堆上的具体对象,主流的访问方式主要有使用句柄和直接指针两种:
如果使用句柄访问的话,java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息;
如果使用直接指针的话,java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息了,reference中存储的直接就是对象实例数据地址。
image.png
使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾回收时移动对象是非常普遍的行为)只会改变句柄中的实例数据地址,而reference不需改变。
使用直接指针来访问的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在java中是非常频繁的,因此这类开销积少成多也是一项极为可观的执行成本。

3.对象的内存布局

在hotspot虚拟机里,对象在堆内存的存储布局可以划分成三个部分:对象头、实例数据和对齐填充。
(1)对象头
对象头包括两类信息,第一类信息存储对象自身的运行时数据,比如哈希码、分代年龄、锁状态标志位、哪个线程持有的锁、是否偏向、偏向线程id、偏向时间戳等信息。以hotspot32位虚拟机为例
image.png
对象头的另外一部分是类型指针,通过这个指针来确定这个对象是哪个类的实例。如果对象是一个数组的话,那在对象头中还必须有一块用于记录数组长度的数据,如果数据在创建时长度就已经固定无法改变了。
(2)实例数据
实例数据部分是对象真正存储的有效信息,也就是我们在程序代码里面所定义的属性内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
(3)对齐填充
hotspot虚拟机的自动自存管理严格要求对象的起始地址必须是8字节的整数倍,因此,如果对象实例数据部分达到8字节的整数倍的话,就需要对齐填充的方式来补全。

4.谈谈java的垃圾回收机制

(1)哪些内存需要被回收?
在java的运行时数据区的各个部分中,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着每一个方法的进入和退出执行着从入栈到出栈的操作。这几个区域不需要过多考虑回收的问题,当方法执行结束或线程销毁,内存自然就跟着回收了。
堆和方法区是被所有线程所共享的数据区,随着虚拟机进程的启动而一直存在。垃圾收集器所关注的是这部分内存该如何管理。
(2)如何判断哪些对象可以被垃圾收集器回收?
其实就是判断哪些对象还存活,哪些对象已经死亡,去回收这些死亡对象的内存空间。
【1】引用计数算法
在对象中添加一个引用计数器,当有其他地方引用它时,引用计数器的值就加一;当引用失效时,计数器就减一;在任何时刻,引用计数为零的对象就是不可能再被使用的。但是这个算法有许多例外情况,必须要配合大量额外处理才能保证正确地工作,比如单纯的引用计数就很难解决对象之间相互循环引用的问题。
ReferenceCountingGC objA=new ReferenceCountingGC();
ReferenceCountingGC objB=new ReferenceCountingGC();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
System.gc();
实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对象导致它们的引用计数都不为零,引用计数算法就无法回收它们。
image.png
image.png
【2】可达性分析算法
基本思路就是通过一系列称为”GC Roots”根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为”引用链”,如果一个对象到GC Roots间没有任何引用链相连,证明此对象是不可能再被使用的。
固定可作为GC Roots的对象包括以下:
[1]在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如正在运行的方法所使用到的参数、局部变量、临时变量等。
[2]在方法区中类静态属性引用的对象。
[3]在方法区中常量引用的对象,比如字符串常量池中的引用。
[4]在本地方法栈中引用的对象。
[5]虚拟机内部的引用,比如基本数据类型对象的Class对象,还要常驻的异常对象以及系统类加载器。
[6]所有被同步锁(synchronized关键字)持有的对象。
(3)分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了”分代收集”的理论进行设计,它建立在两个分代假如之上。
1)弱分代假说:绝大多数对象都是朝生夕死的。
2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。如果一个区域中绝大多数对象都是朝生夕死的,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量的存活对象而不是去标记哪些大量将要被回收的对象,就能以较低的代价回收大量的空间;如果剩下的都是难以消亡的对象,就把它们集中放到一块,虚拟机可以使用较低的频繁来回收这个区域,这样就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。
3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。如果新生代对象存在跨代引用,由于老年代的对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在新生代晋升到老年代中,这样跨代引用也随即消除了。
依据这条假说,我们不应再为少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生minorGC时,只有包含了跨代引用的小块内存里的对象会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系的时候维护数据记录的正确性,会增加一些运行时的开销,但比起扫描整个老年代来说还是划算的。
minorGc:目标只是新生代的垃圾收集。
majorGc:目标只是老年代的垃圾收集,只有CMS收集器会有单独收集老年代的行为。
mixedGc:目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
fullGc:收集整个java堆和方法区的垃圾收集。
(4)垃圾回收算法
【1】标记-清除算法
算法分为标记-清除两个阶段:首先标记出所有需要回收的对象,在标记结束后,统一回收掉所有被标记的对象;也可以反过来,首先标记出所有存活的对象,在标记结束后,统一回收掉所有未被标记的对象。
缺点:
1.执行效率不稳定,如果java堆中包含大量对象,而且其中大部分是需要被回收的,就会导致标记和清除的两个动作的执行效率随着对象数量增长而降低。
2.内存碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要为较大对象分配内存空间而找不到一块足够的连续内存而不得不提前触发另一次垃圾收集动作。
【2】标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低以及产生内存碎片的情况而提出的一种半区赋值的垃圾回收算法,它将可用内存按容量分为大小相等的两块内存空间(A\B),每次只使用其中的一块。当A内存不足以分配对象时而引起垃圾回收时,就把A中还存活着的对象复制到到B内存块,然后把A内存块的对象全部清除,再统一回收内存资源;接着再使用B内存块来分配对象的内存,当B内存不足以分配对象时又会重复A的动作。
特点是:可以避免出现空间碎片,不过会浪费一半的内存,降低内存空间的使用率。
解决方案:Appel式回收:
Appel式回收的具体做法是将新生代分成一块较大的伊甸园区和两块较小的幸存区,每次分配内存时只使用伊甸园区和其中一块幸存区。触发minorGc时,扫描伊甸园区和其中一块幸存区的存活的对象,将他们复制到另一块幸存区,并标记对象的存活年龄+1,然后清理掉伊甸园区和那块幸存区的内存空间。HotSpot虚拟机默认伊甸园区的和幸存区的大小比例是8:1,也就是说新生代中可以用来分配对象内存的空间占整个新生代的90%,只有剩下的10%的新生代是被浪费的。此外,当幸存区里的对象熬过一定次数的垃圾回收年龄满足晋升年龄,就会晋升至老年代。
【3】标记-整理算法
标记整理算法的标记部分并标记清除算法相同,不同的地方在于标记清除算法只是把可回收的对象进行了垃圾回收,不会对剩余的内存空间进行整理,而标记整理算法会对存活着的对象进行整理,以避免产生空间碎片。标记整理算法能够解决标记清理算法产生的内存碎片问题,同时也能避免复制算法的内存空间浪费问题,但标记整理算法的效率比前两者更低。因为整理的过程需要调整对象的引用调整为移动后的地址,此过程耗时严重。是否移动对象都会存在弊端,移动对象则导致内存回收时更为复杂,并且停顿时间也会更长;不移动导致内存分配时更为复杂。但是从整个程序的吞吐量来看,移动对象会更划算。
hotspot虚拟机里关注吞吐量的Parallel Old收集器是基于标记-整理算法的,而关注延迟的CMS收集器是基于标记-清除算法的。
(5)垃圾收集器
【1】Serial/Serial Old收集器
单线程的收集器,但它的”单线程”的意义并不仅仅是说明它只会使用一个处理器或者一条回收线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。Serial收集器依然是hotspot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单且高效。
对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
新生代采用标记-复制算法暂停所有用户线程,老年代采用标记-整理算法暂停所有用户线程。
【2】ParNew收集器
ParNew收集器实质上是Serial收集器的多线程版本,支持多条线程并行进行垃圾收集。
新生代采用标记-复制算法暂停所有用户线程,开启多个gc线程并行完成回收动作。
【3】Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的,也是能够并行收集的多线程收集器。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点在于尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比例。
吞吐量=运行用户代码时间/运行用户代码时间+运行垃圾收集时间
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge收集器主要提供了连个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
这两个参数是彼此不能兼顾的,垃圾收集停顿时间越短就意味着牺牲吞吐量和新生代空间代价换取:系统把新生代调小一些:收集300MB新生代肯定比收集500MB快,但这也会导致垃圾收集的频繁会增加。
【4】Parrllel Old收集器
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑使用Parallel Scavenge+Parrllel Old收集器组合使用的方式。
【5】CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的java应用,都集中在互联网网站或者基于浏览器的B/S架构的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来更好的交互体验。CMS收集器就非常符合这类应用的需求。
1)初始标记(stw)
仅仅标记一下GC Roots能直接关联到的对象,速度很快。但同样也需要所有用户线程执行到一个全局安全点然后停下来,让gc线程做一个初始标记的过程,会在极短时间内stw,然后所有用户线程再恢复运行。
2)并发标记
从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但不需要停顿用户线程,用户线程可以和垃圾收集线程一起并发运行。
3)重新标记(stw)
修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分标记记录,这个过程的停顿时间会比初始标记的停顿时间稍微长一点,但也远比并发标记阶段的时间短。
4)并发清除
清理删除掉标记阶段判断为已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
image.png
缺点:
1.在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总的吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4。
2.无法处理”浮动垃圾”,有可能因为并发失败进而导致另一次stw的fullGC的产生。在cms的并发标记和并发清理阶段,用户线程还是在继续运行着的,程序在运行自然就伴随着新的垃圾对象的产生,但是这部分垃圾对象是出现在标记过程结束以后,cms无法在当次收集中集中处理掉它们,只好等到下一次垃圾收集时再清理掉。
因为垃圾收集阶段用户线程还需要继续运行,所以就需要预留足够的内存空间提供给用户线程使用,因为CMS收集器不能像其他收集器那样等到老年代几乎被填满再进行收集,必须预留一部分空间供垃圾收集时的程序运行适应。
并发失败:如果CMS运行期间预留的内存无法满足程序分配新对象的对象,就会出现一次并发失败,这时候虚拟机不得不启动后备预案:暂停所有用户线程的执行,临时启动Serial Old收集器来重新进行老年代的垃圾回收,但这样停顿时间就很长了。
3.CMS是一款基于标记-清除算法实现的收集器,就意味着在收集结束后会产生大量内存碎片。内存碎片过多的话,给大对象分配内存过程就会比较麻烦,往往会出现整个老年代还有很多剩余空间,但是无法找到足够的连续内存空间分配给它们,而不得不提前触发一次full GC的情况。
【6】G1收集器
(1)特点:
1. 同时注意吞吐量和低延迟,默认的最长GC停顿时间是200ms。
2. 适合超大堆内存的管理,会将堆划分为多个大小相等的region。
3. 整体上是标记-整理算法,两个区域之间是复制算法。
(2)介绍:
在G1收集器出现之前,所有的其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代要么是整个老年代,再要么就是整个java堆的fullGC。而g1垃圾回收器跳出了这个樊笼,它可以面向任何堆内存的任何部分来组成收集集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收利益最大,这就是G1收集器的MixedGC模式。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等独立区域,每个region都可以根据需要,扮演新生代的伊甸园空间、幸存区以及老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。Region中还有一类特殊的Humongous区域,专门用于存储大对象,g1认为只要大小超过了一个region容量的一半即可判定为大对象。而对于超过整个region容量的超级大对象,将会被存放在n个连续的Humongous Region之中。虽然g1依然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,他们是一系列区域的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将region作为单次回收的最小单元,每次收集到的内存空间都是region的整数倍。G1收集器去跟踪各个region里面的垃圾堆积的”价值”大小,价值就是回收所获得的空间大小以及回收所需时间的经验值,然后再后台维护一个优先级列表,每次根据用户设定的允许收集停顿时间(默认200ms)优先回收价值收益最大的那些region。
(3)为什么要对堆空间进行细化呢?
因为g1垃圾回收器可以根据用户设定的最长GC停顿时间来尽量在控制时间内完成部分的垃圾回收工作。如果是以前的垃圾回收器,堆内存比较大的话,每次进行垃圾回收的时候都需要对一整块大的区域进行回收,那收集的时间是不好控制的。而划分成多个小区域后,可以根据用户设定的时间,优先对最优价值的region区域进行回收,以控制回收时间。
(4)GC流程:
G1垃圾收集器主要分为youngGC和young GC+Concurrent Mark,Mixed GC,三个阶段循环进行的。当老年代的内存达到阈值时会进行新生代垃圾回收+并发标记。也有些特殊情况可能会发生fullGC,但比较少见,只有当回收速度跟不上新产生垃圾的速度时才会发生full GC,发生full GC时垃圾回收器会退化成串行单线程的垃圾回收器,会stw,这个过程耗时最长。
image.png
1)MinorGC:minorGC的触发时机伊甸园区空间不足时以分配对象时就会触发minorGC,minorGC同时也是会发生stop the world的,时间相对较短,存活对象以复制算法复制到幸存区。当然幸存区里的对象符合晋升条件的也会晋升到老年代,不够年龄的复制到另一块幸存区。
image.pngimage.png
2)MinorGC+concurrent Mark:在minorGC时会进行GCROOT的初始标记(找到那些根对象),老年代占用堆内存比例达到阈值时,进行并发标记(默认45%),这个过程会从GCROOTS开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段相对耗时,但可与用户线程并发执行,不会stw。
image.png
3)MixedGC:其实主要是分成初始标记、并发标记、最终标记和筛选回收四个步骤,但是前两个步骤在minorGC和minorGC+concurrent Mark阶段已经完成了。进行最终标记的时候会stop the world,用于处理并发阶段结束后仍遗留下来的最后那少量记录。
4)筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收机,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作设计存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。
image.png
(5)G1与cms的比较
优点:
1. G1垃圾回收器与CMS的标记-清除算法不同,G1从整体上来看是基于标记-整理,从局部来看又是基于标记-复制算法实现,因为G1运行期间不会产生内存空间碎片,垃圾收集完成之后还能提供规整的可用内存。这种作用有利于程序长时间运行,在程序为大对象分配内存空间时不会因为无法找到连续内存空间而不得不提前触发下一次收集。
2. G1垃圾回收器可以设定最大停顿时间,按回收价值动态确定回收集。
缺点:
1. G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比cms要高。
2. 就内存占用来说,虽然G1和CMS都是使用卡表来处理跨代指针,但是G1的卡表实现更为复杂,而且堆中的每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致g1的记忆集可能会占整个堆容量的20%乃至更多的内存空间;而CMS相对就简单一些,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要。
3. 就执行负载的角度上来说,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,比如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的卡表维护外,为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化情况,避免CMS那样在最终标记阶段停顿的时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

5.JVM包含哪几个部分?

ClassLoad(类加载器)、RuntimeDataArea(运行时数据区)、ExectionEngine(执行引擎)、NativeInterface(本地库接口)
image.png

  • jvm是java程序的虚拟机系统,首先需要将java代码源文件(扩展名为.class的文件,编程成.class的字节码文件),通过类加载器将Class文件加载到内存中(运行时数据区),但是字节码文件是JVM定义的一套指令集规范,并不能直接交给底层的操作系统去执行,所以需要命令解释器(执行引擎)将字节码文件翻译成特定的操作系统指令集交给CPU去执行,这个过程会调用一些不同语言为java提供的接口,这就使用到了本地Native接口。

ClassLoad:负责将编译好的字节码文件加载到运行时数据库,ClassLoad只负责加载,至于它是否可以运行取决于执行引擎。
RuntimeDataArea:运行时数据区,就是用于存放内存数据的,里面包含了java的内存结构,包括程序计数器、虚拟机栈、堆、方法区、本地方法栈。
ExecutionEngine:执行引擎会在Class字节码文件被加载后,指令和数据信息会存入内存中。执行引擎会把这些指令集翻译成特定的操作系统指令集交给CPU执行。
NativeInterface:本地库接口,负责调用本地接口的,他的作用是调用不同的语言接口给java应用程序来使用。当调用本地方法的时候,执行引擎就会加载对应的本地lib库,能够在本地方法栈中找到对应的本地方法。

6.说一下类加载的过程?

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析这三个部分统称为连接。
加载、验证、准备、初始化和卸载这三个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持java语言的运行时绑定特性(动态绑定)。
【加载】
(1)通过一个类的全限定名来获取定义这个类的二进制字节流
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
【验证】
验证是连接阶段的第一步,这一阶段的目标是确保Class文件的字节流中包含的信息符合《java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。
验证阶段有以下四个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
【准备】
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但方法区是本身是一个逻辑上的区域,在jdk1.7及以后,类变量会随着Class对象一起存放在java堆中;在此之前,hotspot使用永久代来实现方法区,实现还是完全符合逻辑的。
需要注意的是,如果一个类变量使用了final关键字来修饰,那么这个静态变量就是常量,会在准备阶段产生赋值动作。
【解析】
解析阶段是java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的变量出现。
符号引用:用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用:可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
解析阶段过程也包括:1.类或接口的解析 2.字段解析 3.方法解析 4.接口方法解析
【初始化】
类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,java虚拟机才真正开始执行类中编写的java程序代码,将主导权交给应用程序。在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,会根据程序员编码制定的主观计划去初始化类变量和其他阶段。初始化阶段就是执行类构造器()方法的过程,它是javac编译器的自动生成物。()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块static{}块的语句合并产生的。
此外,java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法,如果一个类的()耗时较长,那就有可能造成多个线程阻塞。

7.类加载器

image.png
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层级的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系,好处是java中的类随着它的类加载器一起具备了一种带有优先级的层级关系。例如类java.lang.Object,它它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,java类型体系中最基础的行为也就无法保证,应用程序会变得一片混乱。
源码的实现思路:先检查请求加载的类型是否已经被加载过,如果没有则调用父加载器的loadClass()方法,如果父加载器为空则默认使用启动类加载器作为父加载器。假如父加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。
【破坏双亲委派模型】以tomcat为例
Tomcat是一个web容器,它需要解决以下问题
(1)一个web容器可能需要部署两个以上的应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因为要保证每个应用程序的类库都是独立的,保证相互隔离。
(2)部署在同一个web容器中相同类库相同的版本是可以共享的。否则,如果服务器有10个应用程序,那么就会有10个相同的类库加载进虚拟机。
(3)web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和应用程序的类库隔离开来。
(4)web容器要支持jsp的修改。jsp文件最终也是要编译成Class文件才能在虚拟机中运行,但程序运行后修改jsp文件是常见的事情,web容器需要支持jsp修改后不用重启。
如果Tomcat使用默认的双亲委派模型。
(1)无法加载两个类库的不同版本,默认的类加载器不管你是什么版本的,只在乎你的全限定名,并且只有一份。
(2)默认的类加载器是能够实现的,因为他的职责就是确保唯一性。
(3)第三个问题和第一个问题一样。
(4)每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器然后重新创建类加载器,重新加载jsp文件。
Tomcat的几个主要类加载器:
【1】CommonClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个webapp访问。
【2】CatalinaClassLoader:Tomcat容器私有的类加载器,加载路径中的Class不可以为webapp可见。
【3】SharedClassLoader:各个webapp共享的类加载器,加载路径中的Class可以被所有的webapp访问,但是对于Tomcat容器不可见。
【4】WebappClassLoader:各个Webapp私有的类加载器,加载路径中的Class只对当前webapp可见。比如加载war包里相关的类,每个war包应用都有自己的webappClassLoader,实现相互隔离;如果不同的war包应用引用引入了不同的spring版本,就能实现加载各自的spring版本。每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委派机制。

8JVM什么时候触发GC,如何减少FullGC的次数?

当Eden区的空间耗尽时java虚拟机便会触发一次Minor GC来收集新生代的垃圾,存活下来的对象,则会被送到Survivor区,简单来说就是当新生代的Eden区满的时候触发Minor GC。
当老年代内存剩余空间已经小于之前新生代晋升老年代的平均大小,则进行Full GC。而在CMS等并发收集器则是每隔一段时间检查一下老年代内存的使用量,超过一定比例则进行Full GC回收。
可以采用以下措施来减少Full GC的次数:
(1)增加老年代的空间;
(2)减少新生代的空间;
(3)禁止使用System.gc()方法;
(4)使用标记-整理算法,尽量保持较大的连续内存空间;
(5)排查代码中无用的大对象

9.JVM一次完整的gc流程是怎样的?

新创建的对象一般会被分配到新生代中,常用的新生代的垃圾回收器是ParNew垃圾回收器,它按照8:1:1的比例将新生代划分成伊甸园区以及两个幸存区,如果我们创建的对象将伊甸园区挤满了,此时就会触发minor Gc。在触发minorGc之前,jvm会检查新生代中的对象所占用的内存大小与新生代剩余的空间大小,因为假如minorGc以后幸存区放不下剩余的对象,那那些幸存区的对象就要提前晋升至老年代,所以需要检查老年代的剩余空间是否够用。如果存在以下两种情况:
1.老年代剩余空间大于新生代对象所占用的内存空间,那就直接MinorGc,gc完以后即使幸存区放不小,老年代也绝对放得下。
2.老年代剩余空间小于新生代对象所占用的内存空间,这时候就需要查看是否启动了”老年代空间分配担保规则”。老年代空间分配担保规则是这样的,如果老年代剩余空间大小,大于历次minorGc之后剩余对象的大小,那就允许进行minorGc,因为从概率上来说,以前放得下,这次应该也放得下。所以就存在以下两种情况:
1.老年代剩余空间大于历次minorGc以后剩余对象的大小,那就进行minorGc.
2.老年代剩余空间小于历次minorGc以后剩余对象的大小,那就进行FullGc,把老年代空出来以后再检查而开启老年代分配担保规则只是从概念上来说,minorGc以后剩余的对象能够放到老年代,当然也有万一,minorGC以后一般会有以下3种情况
1.minorGc后幸存区空间足够,剩余对象能够直接放入幸存区,那这种情况是皆大欢喜的。
2.minorGc后幸存区空间不够,但老年代的剩余空间大于新生代对象所占用的内存空间,那就会把剩余的存活对象放入到老年代中。
3.minorGc后幸存区放不下,老年代也放不下,那就只能FullGC了。
当然还有会gc失败的例子,那就会报out of Memory异常了。
1.未开启老年代空间分配担保规则,且一次FullGc后,老年代依然放不下剩余对象,报oom
2.开启了老年代空间分配担保规则,但是担保不通过,一次fullGc后,老年代依然放不下剩余对象,报oom。

10.什么是内存泄漏,怎么解决内存泄漏?

内存泄漏就是程序动态分配的堆内存因为某些原因无法得到释放或未被释放,而一直占用着系统资源,垃圾回收器无法对其进行回收,而导致运行效率低下甚至系统崩溃的问题,内存泄漏终将会导致内存溢出。简单来说就是对象可达但不可用。
而内存泄漏的根本原因在于长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象已经不再需要,但由于长生命周期的对象一直持有他的引用而不能被回收。例如经典的ThreadLocalMap中一个Entry的key为什么它要设置为弱引用类型,假如把他设置成强引用类型,那他的引用链有ThreadLocalRef->ThreadLocal,还有CurrentThreadRef->CurrentThread->Map->Entry->key,这两条强引用链,假设在业务代码中使用完ThreadLocal了,ThreadLocalRef->ThreadLocal这条强引用链断开,但由于还存在着另外一条强引用链,这条强引用链必须得等到当前线程运行结束才能断开,所以它就无法被回收,而我们知道,等到线程运行结束这个点是不好控制的,尤其是使用线程池来创建线程,线程执行完任务后就归还到线程池中,任务结束也不会销毁。而其实这种情况的内存泄漏与Key是强引用还是弱引用没有直接关系,只要你记得使用完ThreadLocal后手动删除这个entry及不会发生内存泄漏。而ThreadLocalMap中的key如果使用了弱引用,那就相当于多施加了一层屏障,使用完ThreadLocal后因为是这个entry的key是弱引用所以会回收掉ThreadLocal这个对象,此时key为null,即使在我们没有手动删除这个entry以及当前线程仍然在运行的前提下,在ThreadLocalMap中的set/getEntry方法中,会对key的值进行判断,如果key为null,那是会对value也设为null的。从而避免内存泄漏。
避免内存泄漏的方法:
1.尽早释放无用对象的引用。
2.避免在循环中创建对象。
3.程序需要进行字符串处理时,尽量避免使用String,而应当使用StringBuffer或StringBuilder。因为String类是不可变类,每个对象都会独立占用一块内存。

11.什么是内存溢出,怎么解决?

内存溢出简单来说就是程序运行时申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有以下几种:
1.内存中加载的数据量过于庞大,如一次从数据库中取出过多数据
2.集合类中有对对象的引用,使用完后未清理,使得jvm不能回收
3.代码死循环或循环过多产生过多重复的对象实例
4.启动参数内存值设置得太小
5.使用的第三方软件中存在Bug
内存溢出的解决方法:
1.修改jvm启动参数,直接增加内存
2.开启错误日志并检查,查看报outOfMemory错误前是否有其他的异常或错误
3.对代码进行分析,找出可能发生内存溢出的位置
4.使用内存查看工具动态查看内存的使用情况。

12.JVM是如何运行的?

JVM的启动过程分为如下四个步骤?
(1)JVM的装入环境和配置;
(2)装载JVM
(3)初始化JVM,获得本地调用接口
(4)运行java程序
JVM运行java程序有两种方式:jar包和class。

13.JVM参数总结

【1】堆内存相关
(1)显示指定堆内存-Xms和-Xmx
举个例子,如果我们要为JVM分配最小2GB和最大5GB的堆内存大小,我们的参数应该这样来写:
-Xms2G -Xmx5G
(2)显式新生代大小
举个栗子,如果我们要为新生代分配最小256m的内存,最大1024m的内存我们的参数应该这样来写:
-XX:NewSize=256m
-XX:MaxNewSize=1024m
或者是NewSize与MaxNewSize的大小要保持一致
-Xmn256m
GC 调优策略中很重要的一条经验总结是这样说的:
将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
另外,你还可以通过-XX:NewRatio=来设置新生代和老年代内存的比值。
比如下面的参数就是设置新生代(包括Eden和两个Survivor区)与老年代的比值为1。也就是说:新生代与老年代所占比值为1:1,新生代占整个堆栈的 1/2。
-XX:NewRatio=1
(3)显式元空间的大小
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
【垃圾收集相关】
(1)显式垃圾收集器
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParNewGC
-XX:+UseG1GC
(2)GC记录
为了严格监控应用程序的运行状况,我们应该始终检查JVM的垃圾回收性能。最简单的方法是以人类可读的格式记录GC活动。
使用以下参数,我们可以记录GC活动:
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=< number of log files >
-XX:GCLogFileSize=< file size >
-Xloggc:/path/to/gc.log