简单说下你对JVM的了解
JVM是Java语言跨平台的关键,Java在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性。运行程序的物理机千差万别,而JVM则在千差万别的物理机上面建立了统一的运行平台,实现了在任意一台JVM上编译的程序,都能在任何其他JVM上正常运行。这一极大的优势使得Java应用的开发比传统C/C++应用的开发更高效快捷,程序员可以把主要精力放在具体业务逻辑,而不是放在保障物理硬件的兼容性上。
通常情况下,一个程序员只要了解了必要的Java类库、Java语法,学习适当的第三方开发框架,就已经基本满足日常开发的需要了,JVM会在用户不知不觉中完成对硬件平台的兼容及对内存等资源的管理工作。
HotSpot是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。
HotSpot既继承了Sun之前两款商用虚拟机的优点,也有许多自己新的技术优势,如它名称中的HotSpot指的就是它的热点代码探测技术。HotSpot的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译行为。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。
JVM由三部分组成:类加载子系统、执行引擎、运行时数据区。
1. 类加载子系统,可以根据指定的全限定名来载入类或接口。
2. 执行引擎,负责执行那些包含在被载入类的方法中的指令。
3. 当程序运行时,JVM需要内存来存储许多内容,例如:字节码、对象、参数、返回值、局部变量、运算的中间结果,等等,JVM会把这些东西都存储到运行时数据区中,以便于管理。而运行时数据区又可以分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。
说说你了解的JVM内存模型
JVM由三部分组成:类加载子系统、执行引擎、运行时数据区。
- 类加载子系统,可以根据指定的全限定名来载入类或接口。
- 执行引擎,负责执行那些包含在被载入类的方法中的指令。
- 当程序运行时,JVM需要内存来存储许多内容,例如:字节码、对象、参数、返回值、局部变量、运算的中间结果,等等,JVM会把这些东西都存储到运行时数据区中,以便于管理。而运行时数据区又可以分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。
运行时数据区是开发者重点要关注的部分,因为程序的运行与它密不可分,很多错误的排查也需要基于对运行时数据区的理解。在运行时数据区所包含的几块内存空间中,方法区和堆是线程之间共享的内存区域,而虚拟机栈、本地方法栈、程序计数器则是线程私有的区域,就是说每个线程都有自己的这个区域。
说说Java运行时数据区
当程序运行时,JVM需要内存来存储许多内容,例如:字节码、对象、参数、返回值、局部变量、运算的中间结果,等等,JVM会把这些东西都存储到运行时数据区中,以便于管理。而运行时数据区又可以分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。
- 程序计数器 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- 虚拟机栈 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 经常有人把Java内存区域笼统地划分为堆和栈,这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是堆和栈两块。其中,栈通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。 在Java虚拟机规范中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
- 本地方法栈 本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。 Java虚拟机规范对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机直接就把本地方法栈和虚拟机栈合二为一。 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
- 堆 堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM将会抛出OutOfMemoryError异常。
方法区 方法区与堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然Java虚拟机规范中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”,目的是与堆区分开来。 运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用。 根据Java虚拟机规范的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
说说JVM的垃圾回收机制
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则。而分代收集理论,建立在如下三个分代假说之上,即弱分代假说、强分代假说、跨代引用假说。依据分代假说理论,垃圾回收可以分为如下几类:
新生代收集:目标为新生代的垃圾收集。
- 老年代收集:目标为老年代的垃圾收集,目前只有CMS收集器会有这种行为。
- 混合收集:目标为整个新生代及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。
- 整堆收集:目标为整个堆和方法区的垃圾收集。
HotSpot虚拟机内置了很多垃圾收集器,其中针对新生代的垃圾收集器有Serial、ParNew、Parallel Scavenge,针对老年代的垃圾收集器有CMS、Serial Old、Parallel Old。此外,HotSpot还内置了面向整堆的G1收集器。在上述收集器中,常见的组合方式有:
- Serial + Serial Old,是客户端模式下常用的收集器。
- ParNew + CMS,是服务端模式下常用的收集器。
- Parallel Scavenge + Parallel Old,适用于后台运算而不需要太多交互的分析任务。
JVM的垃圾回收机制是遵循分代收集理论进行设计的,主要分为四种收集方式:1.新生代收集,目标为新生代的垃圾收集。2.老年代收集:目标为老年代的垃圾收集,目前只有CMS收集器会有这种行为。3.混合收集:目标为整个新生代及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。4.整堆收集:目标为整个方法区和堆的垃圾收集。常见的垃圾回收算法包括:标记清除算法:缺点:内存碎片化,优点:速度快。标记复制算法:缺点占用内存大,优点:内存连续。标记整理算法:优点:内存连续,缺点:整理效率低。
说说JVM的垃圾回收算法
常用的垃圾回收算法有:标记清除算法、标记复制算法、标记整理算法。
- 标记清除算法 算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 它主要有如下两个缺点:
- 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 第二个是内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在运行过程中需要分配较大对象时无法找到足够的连续的内存而不得不提前触发另一次垃圾收集。
- 它主要有如下两个缺点:
- 标记复制算法 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。 这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。另外,如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。所以,现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。
- 标记整理算法 针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
目前,新生代的垃圾回收采用标记复制算法比较多,老年代的垃圾回收采用标记整理算法比较多。而标记复制算法浪费一半内存的缺点长期以来被人诟病,所以业界也有人针对该算法给出了改进的方案。 IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。 Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。 HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。 98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。
说说GC的可达性分析
GC中的可达性分析算法适用于判断对象是否存活。这个算法以根对象为起始节点,根据引用关系向下搜寻,搜索所经过的路径就是“引用链”,如果一个对象没有任何引用链,就说明该对象不会再被使用,要被垃圾回收。常见的GCroot对象:方法区中的静态属性变量。所有堆中实例化对象。方法区中常量池引用对象
请你说说Java的四种引用方式
jdk1.5后,Java中存在四种引用关系,即强引用、软引用、弱引用、虚引用 强引用:是最传统的‘引用’的定义,是指程序代码中普遍存在的引用赋值,只要强引用关系存在,垃圾收集器就永远不会回收被引用的对象。 软引用:用来描述一些还有用,但非必须的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围中,进行第二次回收,若这次回收到足够的内存,才会抛出内存溢出异常 弱引用:也是用来描述一些非必须的对象,但是其强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收发生为止,当垃圾回收器开始工作,无论是否能回收到足够的内存,都会回收掉被弱引用关联的对象。 虚引用:最弱的一种引用关系,为一个对象设置虚引用的唯一目的就是当它被回收时,可以获得一个系统通知
请你讲下CMS垃圾回收器
CMS垃圾收集器采用标记清除算法,使用多线程实现,所以它的应用场景一般为服务端系统的老年代。它是一个以达到在垃圾回收期间用户线程低停顿为目标的垃圾收集器。
CMS垃圾收集器垃圾回收分为四个阶段:
- 初始标记:只对与GCRoots有直接关键的对象进行可达性分析的标记。
- 并发标记:标记整个GCRoots引用链中的对象,与用户线程并发执行。
- 重新标记:用于更新在并发标记过程中被复活的对象。
- 并发清除:清除标记阶段判断的已死亡的对象。该流程与用户线程并发执行
缺点:
- 它使用标记清除算法导致内存碎片化,
会产生在并发清除阶段的浮动垃圾,只有到下一次垃圾回收时才会被清除。
请你讲下G1垃圾回收器
G1收集器是一个多线程的采用标记清除算法的,面向混合收集(同时收集新生带和老年带)的一个垃圾收集器。
G1收集器将整个堆内存区域划分为多个大小相等的region,以region为单位进行垃圾收集并获取每个region的收集效率和收集收益,通过一张优先级表对其进行维护。同时每个region中维护了一个remember set用来存储该分区中的对象所引用的对象在其他分区的位置来避免在做可达性分析算法时全堆扫描。
G1垃圾收集器垃圾回收主要包括四个流程:初始标记
- 并发标记(类似于CMS)
- 最终标记在并发标记中维护了一个remember set log用来记录在该阶段发生变化的对象引用关系,在该阶段就是将该信息同步到最终标记信息中。
并发筛选回收:根据优先级表选择分区进行垃圾回收,用户线程不停顿。
说说类加载机制
类加载的过程中首先判断这个是否被加载过,如果没有被加载过,那么调用类加载器进行加载,判读这个类是否符合规范,如果不符合就抛出异常,加载成功就会生成class对象。
接下来是链接过程,分为三步:准备,验证,准备,解析。
验证:确保文件符合规范,不会危害虚拟机自身的安全,对文件格式,字节码,元数据,符号引用进行验证。
准备:为类变量分配初始空间以及默认初始值,即零值。这里不会为实例变量分配,类变量分配在方法区中,实例变量跟随对象分配在堆中,final修饰的在编译期间就分配了,在准备阶段会显式的初始化。
解析:将常量池内的直接引用转为直接引用的过程。
链接过程完成之后开始初始化的过程: 初始化阶段就是执行类构造器方法的过程。此方法不需要定义,一个类只会被加载一次,虚拟机必须保证在多线程条件下类的构造方法是被加锁的。说说JVM的双亲委派模型
当我们使用一个类时会先对类进行加载,双亲委派模型就是java提供的一种类加载机制。java中默认存在三种类加载器:1,启动类加载器(BootstrapClassLoader),主要用于加载
\lib中的类库。
2,扩展类加载器(ExtClassLoader),主要用于加载\lib\ext文件夹下的类库。
3,应用程序类加载器(Application ClassLoader),用于classpath下的类,即用户自定义的类。启动类加载器存在于jvm层面,使用c++语言编写,用户无法调用。
双亲委派模型建立了一个逻辑上的从上到下的父子关系(启动类加载器,扩展类加载器,应用程序类加载器,这里的父子类不是继承关系),向上委派:当一个类加载器收到一个类加载请求后他不会立即执行类加载而时将请求向上转发交给父类加载器去执行。向下查找:当一个父类加载器无法加载某个类时便会将类加载的请求向下转发交给子类实现。双亲委派模型最大的优点是可以避免类的覆盖。说说类的实例化过程
当JVM遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
内存分配完成之后,虚拟机必须将分配到的内存空间都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。 一般来说,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。请你说说内存溢出
内存溢出,简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有以下几种:内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
- 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
- 代码中存在死循环或循环产生过多重复的对象实体;
- 使用的第三方软件中的BUG;
- 启动参数内存值设定的过小。
内存溢出的解决方案:
- 第一步,修改JVM启动参数,直接增加内存。
- 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
- 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
- 第四步,使用内存查看工具动态查看内存使用情况。
除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。
- Java堆溢出 Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
- 虚拟机栈和本地方法栈溢出 HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
- 方法区和运行时常量池溢出 方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。
- 本地直接内存溢出 直接内存的容量大小可通过
-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值一致。如果直接通过反射获取Unsafe实例进行内存分配,并超出了上述的限制时,将会引发OOM异常。请你说说内存泄漏
内存泄漏,是指不再使用的对象仍然被引用,导致垃圾收集器无法回收它们的内存。由于不再使用的对象仍然无法清理,甚至这种情况可能会越积越多,最终导致致命的OutOfMemoryError。请你说说内存管理
内存管理主要包括内存的分配(malloc)和释放(free),内存的分配可以分为连续内存分配和非连续内存分配。
- 连续分配:为用户分配一个连续的内存空间,常见的连续分配如块式管理。
- 非连续分配又分为页式存储管理,段式存储管理和段页数存储管理。
- 页式存储管理:页是数据分配的物理单位,页分配是为了实现数据的离散分布,提高内存利用率。它的分区大小是固定的,页分配相较于块有着更小的分配粒度,可能会导致内部内存碎片,不会导致外部内存碎片。通过页表实现物理地址于逻辑地址的对应。
- 段式存储管理:段是数据分配的逻辑单位,段式分配目的是为了更好的反映程序的逻辑结构以更好满足用户需求。它的分区大小是不固定的,会导致外部内存碎片,段相比页拥有实际意义,可以将其分为主程序段,子程序段,数据段和栈段。段是一个二维结构,定位段需要知道它的段名和段内具体物理地址。使用段表来对应物理地址和逻辑地址。
- 段页是存储分配:结合段式分配和页式分配,将主内存区域先分为多个段,在将每个页划分多若干页。结合多段式管理和页式管理的各种优点。
请你说说虚拟内存和物理内存的区别
物理内存:计算机中真正拥有的内存,物理内存是有限的,如果没有虚拟内存,寻址范围固定为2^系统位数,每个进程执行时都给这个内存,执行完成后这个内存再装入其他进程;指令直接访问物理内存,那么任何进程都可以修改其他进程的数据。 虚拟内存是计算机内存管理的一种技术,它使得程序认为可以拥有连续的内存,分成多个物理碎片,在需要时进行数据交换
