《面试必备知识》——Java虚拟机(JVM)78问
本期分享的是与java虚拟机相关的面试题,希望对大家有帮助。
1. Java中的类什么时候会被加载?
- 上述六种行为称为对一个类型的主动引用,会触发对应类型的初始化
- 反之,被动引用不会触发对应类型的初始化
- 在上述代码中,通过子类来引用父类中定义的静态字段,此时只会触发父类的初始化,但是不会触发子类的初始化
- 在hotspot虚拟机上,该过程会触发子类的加载,但是静态代码块的执行需要经历初始化阶段,因此也不会有输出
- 通过数组定义来引用类,不会触发该类的初始化过程
- 对于类的静态常量而言,编译成class文件后,会将常量直接存储在引用该常量的类的常量池中,这样子两个class文件实际上就不存在任何联系了。
2. 静态代码块可以访问定义在后面的静态变量吗?
- 静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问
3. 垃圾回收算法有哪些?
- 标记 - 清除
- 在标记阶段,根据可达性算法标记出相应的可回收对象。
- 在清除阶段,对可回收对象进行回收。另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
- 在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
- 不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
- 标记 - 整理
- 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:
- 不会产生内存碎片
- 不足:
- 需要移动大量对象,处理效率比较低。
- 复制
- 将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
- 主要不足是只使用了内存的一半。
- 现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
- HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
- 分代收集
- 现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
- 一般将堆分为新生代和老年代。
- 新生代使用:复制算法
- 老年代使用:标记 - 清除 或者 标记 - 整理 算法
4. Java的类加载流程介绍一下?
-
加载
- 指Java虚拟机查找字节流(查找.class文件),并且根据字节流创建java.lang.Class对象的过程。这个过程,将类的.class文件中的二进制数据读入内存,放在运行时区域的方法区内。然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构
- Java虚拟机将.class文件读入内存,并为之创建一个Class对象
- 任何类被使用时系统都会为其创建一个且仅有一个Class对象。
- 这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等。
-
验证
- 确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
-
准备
-
负责为类的静态成员分配内存,并设置默认初始值。
-
类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
-
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次
-
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
public static int value = 123; -
如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是0。
public static final int value = 123;
-
-
解析
- 将类的二进制数据中的符号引用替换为直接引用。
- 符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
- 直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。
- 举个例子来说,现在调用方法hello(),这个方法的地址是0xaabbccdd,那么hello就是符号引用,0xaabbccdd就是直接引用。
- 在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
- 其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定
- 将类的二进制数据中的符号引用替换为直接引用。
-
初始化
- 初始化,则是为标记为常量值的字段赋值的过程。换句话说,只对static修饰的变量或语句块进行初始化。
- 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
- 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
5. 请介绍一下java运行时内存区结构?
- 运行时内存结构
- Java虚拟机规范定义的运行时数据区:
- HotSpot JDK1.8定义的运行时数据区
- HotSpot实现的运行时数据区和Java虚拟机规范定义的还是有所不同的:
- 将Java虚拟机栈和本地方法栈合二为一;
- 元数据区取代永久代实现方法区,并且元数据区不在Java虚拟机中,而是在本地内存中。
- 运行时常量池由方法区中移到了堆中
- 程序计数器:通过改变该计数器的值来选取下一条需要执行的字节码指令。可以用来记录线程运行时的状态。
- 线程私有
- 如果执行的是JAVA方法,记录的是正在执行的虚拟机字节码指令的地址;如果是native方法,则计数器的值为空(undefined)
- 空间大小固定,不抛出内存溢出异常。不需要进行GC
- 虚拟机栈:与线程同时创建,用于存储栈帧。Java 每个方法执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 该区域入栈和出栈的时机明确,不需要进行GC
- 线程私有
- 由栈帧组成
- 抛出 StackOverflowError 和 OutOfMemoryError 异常
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
- 本地方法栈:作用和虚拟机栈类型,虚拟机栈执行的是Java方法,本地方法栈执行的是 Native 方法,本地方法栈也会抛出抛出 StackOverflowError 和 OutOfMemoryError 异常。
- 该区域也不需要GC
- 本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
- Java堆:Java虚拟机所管理内存最大、被所有线程共享的一块区域,目的是用来存放对象,基本上所有的对象实例和数组都在堆上分配(不是绝对)。Java堆也是垃圾回收器管理的主要区域。
- 线程共享:
- 如果堆不是线程共享的话,那么多线程同时工作就都必须给他们分配相应的私有内存,如果某些线程创建的对象实例较大的话,很快就会导致内存溢出。
- 存放对象:
- 基本上所有的对象实例和数组都要在堆上进行分配。
- 垃圾回收
- Java堆也被称为“GC堆”,是垃圾回收器的主要操作内存区域。
- 抛出 OutOfMemoryError 异常
- 线程共享:
- 方法区:用来存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区是堆的逻辑组成部分
- 又被成为“永久代”,由于垃圾回收器对该区域的垃圾回收较少,主要针对常量池的回收以及对类型的卸载,回收条件比较苛刻。容易导致对此内存未完全回收而导致内存泄露。最后当方法区无法满足内存分配时,将抛出 OutOfMemoryError 异常。
- 运行时常量池:在Java虚拟机规范中,运行时常量池(Runtime Constant Pool)用于存放编译期生成的各种字面量和符号引用,是方法区的一部分。
- 存放字面量、符号引用、直接引用
- 通常来说,该区域除了保存Class文件中描述的引用外,还会把翻译出来的直接引用也存储在运行时常量池,并且Java语言并不要求常量一定只能在编译器产生,运行期间也可能将常量放入池中,比如String类的intern()方法。
- 抛出 OutOfMemoryError 异常:
- 运行时常量池是方法区的一部分,会受到方法区内存的限制,当常量池无法申请到内存时,会抛出该异常。
- 存放字面量、符号引用、直接引用
- 本地内存:又称为堆外内存,包含元空间和直接内存。
- java8之前方法区靠永久代来进行实现,该部分由于是在堆中实现的,受GC管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM
- 因此 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能(发生 GC 会发生 Stop The Word,造成性能受到一定影响,后文会提到),也就不存在由于永久代限制大小而导致的 OOM 异常了(假设总内存1G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。综上所述,在 Java 8 以后这一区域也不需要进行 GC
- 直接内存:并不是虚拟机运行时数据区的一部分,它也不是Java虚拟机规范定义的内存区域。这块区域是受本机物理内存的限制,当申请的内存超过了本机物理内存,才会抛出 OutOfMemoryError 异常。
- 在JDK1.4中新加入的 NIO(new input/output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer 对象作为这块内存的引用操作,这样避免了在Java堆和Native堆中来回复制数据,显著提高性能。
6. 请画出堆区的结构图,不同区采用什么gc方法?
- 对象流转过程
- 对象A被new出来之后,是被存放在Eden区的。注释:Eden即伊甸园,亚当和夏娃的故事
- 当发生一次GC之后,Eden区存活下来的对象A会被复制到Survivor 1区(此时Survivor 1为To Survivor);Survivor 0 (此时为From Survivor)中存活的对象也会被复制到Survivor 1中。
- GC会清空Eden和Survivor 0 (即From Survivor)中存储的所有对象。因为Eden和Survivor 0 中存活的对象都被复制到 Survivor 1中了,所以清空是没问题的。
- 交换Survivor 0和Survivor 1的角色:即此时有数据的Survivor 1作为From Survivor,被清空的Survivor 0作为To Survivor。要保证在GC发生之前,To Survivor永远是空的那个
- 下次GC发生时,重复上述步骤。将Eden中存活的对象复制到To Survivor,将From Survivor中活的对象也复制到To Survivor。
- 说到此处,有细心的同学会发现,这都是新生代之间的作用,那老年代呢?
- 其实是这样的,在上述步骤中,发生GC时,From Survivor中存活的对象并不是全部都会被复制到To Survivor中,而是根据这个对象在Survivor区中存活了多久而决定去向,当一个对象在Survivor中存活了很久(即经历了多次GC还没死),就会在发生GC时被复制到旧生代中。
- 新生代:
- 复制法进行垃圾回收
- 老年代:
- 标记-清除
- 标记-整理
7. 介绍一下CMS收集器的工作流程?
- CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。作用于老年代
- 流程
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除:不需要停顿。
- 在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
- 缺点:
- CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure(并发模式故障)。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
8. 介绍一下G1收集器特点及收集流程?
-
是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能 。能够利用多个CPU来缩短停顿时间。
-
G1 可以直接对新生代和老年代一起回收。
-
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
-
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
-
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
-
运行流程:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收 :首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
-
特点:
- 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
9. CMS收集器和G1收集器的区别?
- 使用范围不一样:
- CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用
- G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用
- STW的时间:
- CMS收集器以最小的停顿时间为目标的收集器。
- G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)
- 垃圾碎片:
- CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
- G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。
- 垃圾回收过程不一样:
- 见上
10. 如何判断一个对象是否可被回收?
- 引用计数法:
- 为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
- 在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
- 可达性分析算法:
- 以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
- Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法栈中 JNI 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
- 方法区的回收 :
- 因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
- 主要是对常量池的回收和对类的卸载。
- 为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
- 类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
- finalize() :
- 类似 C++ 的析构函数,在对象销毁前被调用,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用
- 当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。
11. 类加载器有哪几类?
- JAVA虚拟机角度:
- 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
- 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。
- Java开发人员角度:
- 启动类加载器(Bootstrap ClassLoader):
- 此类加载器负责将存放在 <JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
- 启动类加载器无法被 Java 程序直接引用 。
- 扩展类加载器(Extension ClassLoader) :
- 它负责将 <JAVA_HOME>/lib/ext 或者被java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader) :
- 由于这个类加载器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。
- 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
- 启动类加载器(Bootstrap ClassLoader):
12. 类加载器为什么采用组合而不是继承?
- 类加载器可以分为启动类加载器、扩展类加载器、应用程序类加载器。
- 应用程序是由三种类加载器互相配合从而实现类加载 。
- 类加载器之间的层次关系采用双亲委派模型。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。 这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。
- 通过组合关系,一个类可以聚合多个类,调用多个类中的属性。进而可以将类加载的任务委派给适合的对象。
- 类加载器通过组合关系,可以先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。
- 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
13. 如何自定义类加载器?
- 需要继承ClassLoader并且重写findClass方法
- 自定义类加载器的默认父类加载器是应用类加载器
class MyClassLoader extends ClassLoader { private String path = null; // 设置自定义类加载器的目录 public MyClassLoader(String path) { this.path = path; } /* * findClass和loadClass的区别? */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { try { File f = new File(path, name.substring(name.lastIndexOf('.') + 1) + ".class"); FileInputStream fis = new FileInputStream(f); ByteArrayOutputStream bos = new ByteArrayOutputStream(); int ch = 0; while ((ch = fis.read()) != -1) { bos.write(ch); } byte[] buf = bos.toByteArray(); //解密.class buf = Z1Encrypt.crypt(buf, "decrypt"); fis.close(); bos.close(); //根据字节码返回这个类 return defineClass(name, buf, 0, buf.length); } catch (Exception e) { throw new ClassNotFoundException(name + " is not found!"); } } } // 用自定义的类加载器去解密加载加密好的字节码文件 public static void main(String[] args) throws Exception { /*Z1Encrypt.readClass("F:\\WorkSpace\\classLoader\\DemoTemp.class"); Z1Encrypt.encrypt(); */ MyClassLoader mcl = new MyClassLoader("F:\\WorkSpace\\classLoader\\"); Class clazz = mcl.findClass("DemoTemp"); Method me = clazz.getMethod("say",null); Object obj = clazz.newInstance(); me.invoke(obj, null); }
- findclass()方法用于写类加载的逻辑
- loadclass()方法用来保证双亲委派机制,它会先调用父类加载器,如果父类加载失败,才会调用自己的findclass()方法来完成加载
- 如果不想打破双亲委派模型,那么只需要重写findClass方法即可
- 如果想打破双亲委派模型,那么就重写整个loadClass方法
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
- 因此,如果想使用指定的类加载器来加载自己的类的话,可以直接使用自定义的类加载器的findclass方法来加载
14. JAVA 内存模型三大特性是什么?
- 原子性:
- 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性
- AtomicInteger:原子操作类。对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。
- 可见性:
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 实现可见性方法:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- Lock
- 有序性:
- 即程序执行的顺序按照代码的先后顺序执行。
- 在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
- 实现有序性:
- volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
- 通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
- 先行发生原则 :让一个操作无需控制就能先于另一个操作完成。(happens-before)
- 单一线程原则 :在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 管程锁定规则 :一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile 变量规则 :对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 线程start规则 :Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 线程join规则 :Thread 对象的结束先行发生于 join() 方法返回。
- 线程中断规则 :对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
- 对象终结规则 :一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
- 传递性 :如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
15. 类加载器的双亲委派机制介绍一下?为什么要采用该机制?
- 类加载器分为三类:启动类加载器、扩展类加载器、应用程序类加载器。
- 这三种类加载器之间的层次关系,成为双亲委派模型。
- 该模型要求除了启动类加载器以外,其它的类加载器都要有自己的父类加载器。
- 一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。
- 该模型使得JAVA类随着它的类加载器一起具有一种优先级层次关系,从而使得基础类得到统一。
16. 如何减少full gc的次数?
- Full GC次数太多了,如何优化
- 首先要尽量避免调用System.gc()方法
- 如果出现老年代空间不足导致full gc次数变多的话,应该让对象在Minor GC阶段被回收,以及不要创建过大的对象和数组。
- 如果出现永久代空间不足导致full gc次数变多的话,可采用的方法为增大永久代空间或转为使用CMS GC
- 如果是CMS GC时出现promotion failed和concurrent mode failure的话可能会触发full gc。
- promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成的
- 执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)
- 措施为:增大survivor space、老年代空间或调低触发并发GC的比率
- 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间
- 堆中分配很大的对象:老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象
- CMS垃圾收集器提供了一个可配置的参数,即-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费赠送一个碎片整理的过程。但是停顿时间会变长
17. 方法区是否需要gc?
- 需要
- 方法区的垃圾回收主要分两种:
- 废弃常量的回收(常量池的回收)
- 当一个常量对象不再任何地方被引用的时候,则被标记为废弃常量,这个常量可以被回收。
- 无用类的回收(类的卸载)
- 该类所有的实例都已经被回收, 也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收;
- 该类对应的java.lang.Class对象不在任何地方被引用,且无法在任何地方通过反射访问该类的方法。
- 当满足上述三个条件的类才可以被回收,但是并不是一定会被回收,需要参数进行控制,例如HotSpot虚拟机提供了-Xnoclassgc参数进行控制是否回收
- 废弃常量的回收(常量池的回收)
18. 什么是GC Roots?
- 在进行垃圾回收的时候,可以通过枚举根节点做可达性分析来判断一个对象是否可以被回收。
- 该方法是通过一系列名为“GC Roots”的对象作为起始点,从“GC Roots”对象开始向下搜索,如果一个对象到“GC Roots”没有任何引用链相连,说明此对象可以被回收。
19. 哪些对象可以作为GC Root?
- 虚拟机栈中局部变量(局部变量表)引用的对象
- 方法区中类的静态属性、常量引用的对象
- 本地方法栈中的JNI(Native方法)的引用对象
20. 为什么选择这些对象作为GC ROOT?
- 个人感觉:
- 这些GC ROOT节点主要位于全局性的应用(例如常量或类静态属性)与执行上下文(例如 栈帧中的本地变量表)中
- 能够确保这些节点都是强引用,而且比较容易找到。
- 当类加载完毕后,在该类的什么位置偏移上是什么类型的变量都已经确定了,因此选择这些变量能够快速定位
21. 查看GC状态的命令有哪些?
- jstat -gc 12538 5000
-
-
22. 简单介绍一下对象分配以及垃圾回收的流程?
23. 如何识别垃圾?
- 引用计数法
- 循环引用问题
- 可达性算法
- 以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。。。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。
24. 被判断为垃圾一定会被回收吗?
- 不一定。
- 对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!
- finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!
25. 什么对象会成为GC ROOTS?
- 虚拟机栈中的局部变量所引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中native方法引用的对象
26. 本地方法是什么?
- 本地方法就是java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使 JAVA 可以实现和本地机器的紧密联系,调用系统级的各接口方法
27. 对象何时晋升到老年代?
- 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代
- 大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.
- 还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
- 空间分配担保:
- 在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么Minor GC 可以确保是安全的,如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC,否则可能进行一次 Full GC。
28. 什么是STW?
- 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
29. 堆进行分区以及新生代进行分区的原因?
- 由于FULL GC会清理整个堆中的不可用对象,一般要花较长的时间,因此一般 Full GC 会导致工作线程停顿时间过长
- 所以要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)
- 因此,将新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。
- Safe Point:
- 由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point。
- 要求:
- 点数不能太少,让 GC 时间太长导致程序过长时间卡顿
- 点数不能过多,以至于过分增大运行时的负荷
- 一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC
- 特定位置:
- 循环的末尾
- 方法返回前
- 调用方法的 call 之后
- 抛出异常的位置
30. 新生代和老年代采用不同垃圾回收方法的原因?
- 新生代由于大部分对象在经过Minor GC后会消亡,存活的对象少,因此 Minor GC 用的是复制算法
- 老年代的对象比较多,而且占用的空间大,使用复制算***有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间),因此老年代进行GC一般采用标记整理方法来进行回收。
31.垃圾收集器的种类有哪些?请分别介绍一下?
-
- 不同垃圾收集器的特点
- serial:
- 新生代
- 单线程
- GC期间,用户线程停止
- 在 Client 模式下,它简单有效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 单线程模式无需与其他线程交互,减少了开销,专心做 GC 能将其单线程的优势发挥到极致
- 另外在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆(仅是新生代的内存,桌面应用基本不会再大了),STW 时间可以控制在一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的,
- 运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器
- ParNew 收集器:
- serial的多线程版本,除了线程数以外,与serial基本一样
- 主要工作在server模式。
- 多线程可以让垃圾回收更快,减少STW时间,提升响应速度,是许多运行在 Server 模式下的虚拟机的首选新生代收集器
- 除了 Serial 收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器。
- 在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收***更快,也能有效地减少 STW 的时间,提升应用的响应速度。
- Parallel Scavenge:
- 多线程,复制算法,新生代
- 其余收集器的目标是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge 目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间))。
- 即CMS 等垃圾收集器更适合用于与用户交互的程序,因为停顿时间越短,用户体验越好,而 Parallel Scavenge 收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
- Serial Old:
- 老年代,单线程。老年代版本的Serial
- 主要意义在于给 Client 模式下的虚拟机使用
- 在 Server 模式下,则它还有两大用途:
- 一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,
- 另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用(后文讲述),它与 Serial 收集器配合使用示意图如下
- Parallel Old:
- Parallel Scavenge 收集器的老年代版本
- 多线程和标记整理方法
- 与Parallel Scavenge一起使用,真正实现了「吞吐量优先」的目标
- CMS 收集器:
- 以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!
- 标记清除方法
- 步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
- 详见另外一个CMS的问题回答
- 不足:
- CMS 收集器对 CPU 资源非常敏感 原因也可以理解,比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
- CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生。由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
- CMS 采用的是标记清除法,上文我们已经提到这种方***产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
- G1(Garbage First) 收集器
- G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器
- 目标是在延迟可控的情况下获得尽可能高的吞吐量
- 特点:
- 像 CMS 收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 更好预测 GC 需要的停顿时间。
- 不会像 CMS 那样牺牲大量的吞吐性能。
- 不需要更大的 Java Heap
- 与 CMS 相比,它在以下两个方面表现更出色:
- 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
- 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。
- 传统垃圾收集器的对堆空间的分配是连续的。G1收集器各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址
-
- 除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?
- 传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需开销),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。
- 步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
- serial:
32. Java堆内存都是线程共享的吗?
- 由于堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么就有可能出现多个线程将对象引用指向同一个内存区域
- 针对这个问题,需要进行同步控制
- 但是,同步控制必然会影响效率
- 因此,HotSpot虚拟机采用了TLAB分配方案,即Thread Local Allocation Buffer。在从堆中划分出来,由本地线程独享(内存分配上时独占的,读取是共享的)
33. TLAB介绍一下?
-
线程初始化时,虚拟机会为每个线程分配一块TLAB空间(eden区分配,默认占1%空间),只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率
-
因此,堆空间不是完完整整的线程共享,对于TLAB是线程独享的(仅限于分配操作)(读取和垃圾回收都是线程共享的)
-
由于TLAB较小,因此对于大对象无法直接分配
-
因此对于这些大对象,只能在eden区或者老年代进行分配,此时就需要同步控制,因此,小的对象比大的对象分配起来更加高效
-
存在问题
- 当TLAB的空间不够分配给新的对象后
- 要么直接分配到堆中
- 可能导致后续的请求大部分都要在堆中分配
- 要么重新申请TLAB空间
- 有可能出现频繁废弃、申请TLAB的情况
- 定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”
- 新对象大于该值,则在堆中分配
- 否则,申请新的TLAB空间分配
34. 虚拟机栈的内存溢出有遇到过吗?介绍一下原因和解决方法
-
虚拟机规范中定义了两种异常:
- 请求的栈深度超过最大深度,抛出StackOverflowError异常
- 如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时, 将抛出OutOfMemoryError异常
-
HotSpot虚拟机是不支持栈的动态扩展的,因此除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常
-
对两种情况进行验证
- 使用-Xss参数减少栈内存容量
- 使用递归调用的方法来创建多个栈帧
- 结果:抛出StackOverflowError异常, 异常出现时输出的堆栈深度相应缩小
- 定义了大量的本地变量, 增大此方法帧中本地变量表的长度
- 同样使用递归调用的方式来创建多个栈帧
- 结果:同上
- 但是对于支持栈动态扩展的虚拟机,会抛出OutOfMemoryError 错误
-
通过多线程的方式不断创建线程的方式也可以在HotSpot上产生内存溢出异常。但是该情况与栈空间是否足够不存在任何直接关系,主要取决于系统的内存使用状态。而且此时每个线程的栈内存分配越大,越容易产生内存溢出。
35. 方法区和运行时常量池溢出的原因和解决方法?
运行时常量池溢出
- JDK6及以前,字符串常量池位于永久代,而在JDK7及以后,字符串常量池移动至Java堆中
- 因此,在JDK6以前,通过不断的调用String.intern方***导致运行时常量池溢出(永久代的内存溢出)
- 而在JDK7及以后,通过限制堆的大小也能看到内存溢出的现象,只不过是堆内存溢出异常
String.intern
- String.intern方法返回引用的测试
-
在JDK6中,结果为两个false。因为在JDK6中,intern方***将第一次遇到的字符串实例复制到永久代的字符串常量池中存储, 返回的也是永久代里面这个字符串实例的引用 , 而由StringBuilder创建的字符串对象实例在Java堆上, 所以必然不可能是同一个引用, 结果将返回false
-
在JDK7及以后,intern()方法实现就不需要再拷贝字符串的实例到永久代了, 既然字符串常量池已经移到Java堆中, 那只需要在常量池里记录一下首次出现的实例引用即可, 因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个
-
而对str2比较返回false, 这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了, 字符串常量池中已经有它的引用, 不符合intern()方法要求“首次遇到”的原则
-
因此,总结下来,对于第一次遇到的字符串,StringBuilder.toString()返回的对象与字符串常量池中的对象是同一个,而非第一次遇到的字符串,由于str2是通过new一个对象得到的,自然他是堆中的一个新对象,而str2.intern方法返回的就是字符串常量池中已有的对象,两者自然不是同一个对象
-
动态代理生成的动态类导致方法区内存溢出
-
在JDK7中会抛出永久代的内存溢出异常
-
在JDK8以后,通过元空间代替永久代,在默认设置下,动态创建新类比较难使虚拟机产生方法区的溢出异常
-
但还是可以设置元空间最大值、元空间的初始大小等参数来作为元空间的防御措施
36. 直接内存溢出?
- 可以通过设置最大的直接内存参数来设置最大的直接内存的大小
- 如果不设置,则默认与Java堆的最大值一致
37. 局部变量表对垃圾收集的影响有哪些?
- 局部变量表的变量槽是可以复用的,在某些场景下,局部变量表槽的复用会对垃圾收集造成影响
- 在上述代码中,placeholder的作用域为花括号内部
- 但是在出了花括号后,对应的内存却没有被回收
-
将代码改成上述形式后,即可完成回收的功能
-
这是因为后者修改了局部变量表中变量槽的引用,导致变量槽中不再存在对placeholder的引用,使得该部分的内存能够正确的回收
-
所以可以在当无需再次使用局部变量的情况下,将局部变量置为null
-
值得注意的是,当虚拟机使用解释器执行时, 通常与概念模型还会比较接近, 但经过即时编译器施加了各种编译优化措施以后, 两者的差异就会非常大, 只保证程序执行的结果与概念一致
-
在实际情况中, 即时编译才是虚拟机执行代码的主要方式
-
赋null值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的, 这时候将变量设置为null就是毫无意义的行为。
-
字节码被即时编译为本地代码后, 对GC Roots的枚举也与解释执行时期有显著差别, 以前面的例子来看, 经过第一次修改的代码清单8-2在经过即时编译后, System.gc()执行时就可以正确地回收内存, 根本无须写成代码清单8-3的样子
38. 重载匹配顺序?
- 首先,会第一个匹配参数类型为char的方法
- 然后,可以匹配int类型的方法
- 还可以匹配long类型的方法,这里是用了两次自动类型转换
- 自动类型转换可以连续发生多次
- 按照char>int>long>float>double的顺序转型进行匹配, 但不会匹配到byte和short类型的重 载, 因为char到byte或short的转型是不安全的
- 接着匹配到Character的包装类,进行了一次自动装箱
- 接下来匹配到了Serializable。这里是先进行了一次自动装箱,发现Character实现了对应的接口,因此可以继续往上转型到接口类型
- 然后可以调用Object的方法,这是从Character转型到最顶层的父类Object。如果有多个父类, 那将在继承关系中从下往上开始搜索, 越接近上层的优先级越低。注意,即时传入参数为null也可以调用该方法进行匹配
- 最后就是可变长度的方法匹配,char...,传入的参数被当做为一个char[]数组
39. 字段有多态性吗?
40. 泛型底层实现原理是什么?
- 当泛型遇见重载
- 类型擦除后,两个方法的特征签名变得一模一样。
41. 包装类相等问题
42. volatile底层原理是什么?
- volatile的可见性保证以及内存屏障的底层原理
43. Java虚拟机保证的原子性操作有哪些?
- 对于long和double存在例外
44. 先行先发生原则有哪些?
45. Java线程的实现方式是什么?
46. 线程的状态有哪些?
47. 对象头的组成介绍一下?
- mark word
48. JVM性能监控与故障处理
48.1 基础故障处理工具有哪些?
- jps: 虚拟机进程状况工具
- 可以列出正在运行的虚拟机进程, 并显示虚拟机执行主类(Main Class, main()函数所在的类) 名称以及这些进程的本地虚拟机唯一ID(LVMID, Local Virtual Machine Identifier)
- jstat: 虚拟机统计信息监视工具
- 用于监视虚拟机各种运行状态信息的命令行工具
- jinfo: Java配置信息工具
- 实时查看和调整虚拟机各项参数
- jmap: Java内存映像工具
- 用于生成堆转储快照
- vmid就是java进程号
- jhat: 虚拟机堆转储快照分析工具
- 与jmap搭配使用, 来分析jmap生成的堆转储快照
- jstack: Java堆栈跟踪工具
- 用于生成虚拟机当前时刻的线程快照
- 线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合, 生成线程快照的目的通常是定位线程出现长时间停顿的原因, 如线程间死锁、 死循环、 请求外部资源导致的长时间挂起等, 都是导致线程长时间停顿的常见原因。
- 线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情, 或者等待着什么资源。
48.2 可视化故障工具处理有哪些?
- 下面两个用得比较多
- 均位于java安装目录下的bin目录中
49. 类加载器的作用是什么?
50. 对象内存分配方法有哪些?
- 对于规整的内存,使用指针碰撞
- 对于不规整的内存,使用空闲列表
51. CMS收集器为什么不使用标记整理算法?
- CMS收集器在进行垃圾回收的时候,是跟用户线程并发执行的
- 如果使用标记整理算法,那么在进行清理的时候,就需要移动存活的对象,一旦移动存活对象后,用户程序就很有可能找不到对应的对象存放在哪里
- 同理,复制算法也不行,因为需要将存活对象移动到另外一块内存中
52. 复制算法如何修改对象的引用指向新的地址?
- 复制算法通过逐层遍历GC root的引用关系图,将存活对象复制到另外一块内存中
- 当复制完毕后,将原有的引用修改为新的地址
- 复制算***在新的内存中维护一个用于分配空间的指针
53. 堆出现OOM如何处理?
-
首先通过jmap来生成堆的转存快照
-
然后再使用内存映像分析工具(jvisualvm)对堆转存快照进行分析,确认内存中的对象是否是必要,是否存在内存泄漏的情况
-
如果存在内存泄漏,则进一步查看泄漏对象到GC Roots的引用链,分析造成内存泄漏的原因
-
如果不存在内存泄漏,就检查虚拟机的参数(-Xms和-Xmx)是否设置正常
-
直接使用jmap来生成堆的转存快照可能不是堆溢出时候的内存快照,最好通过虚拟机参数设置让其在内存溢出的时候自动导出内存快照
| 参数 | 说明 |
|---|---|
| -XX:+HeapDumpOnOutOfMemoryError | 内存溢出时自动导出内存快照 |
| -XX:HeapDumpPath=E:/dumps/ | 导出内存快照时保存的路径 |
-
还要注意,oom是发生在请求内存大于实际拥有内存的时刻,此时内存不一定是100%占用
-
如下图所示,对生成的转存快照文件,按照类的实例占用的大小进行排序就可以看出导致OOM的原因是由于对象创建过多,还是堆空间分配不合理(一个大对象都放不下)
- 具体文件导入方法可以参考
54. 内存泄漏问题如何定位?如何解决?
-
定义:
- 仍然具有GC ROOT根引用但后续不会再在应用代码中使用的对象
- 对象过多或者过大,导致程序没有足够的堆空间运行
- 临时对象太多
-
第一种是传统意义上的内存泄漏,也是最常见
-
后两种多属于写代码不规范引起
-
堆的OOM也有可能是内存泄漏造成的
-
一个明显的现象是JVM中老年代在持续的增长
-
内存泄漏与内存溢出的明显区别:内存泄漏中,老年代的增长情况时缓慢上升的
-
问题定位
- 内存泄漏的定位需要保存多个不同时间点的堆的转存快照
- 然后可以利用jvisualvm来对这些转存快照进行分析(在类的页面中,通过将该堆与另外一个时间点的堆的快照进行比较)
- 如果某一个类的实例数目随着时间不断累积的话,应该就是出现内存泄漏问题了
- 接下来可以查看不断增加的实例的引用关系,根据引用关系来进一步分析原码中哪些地方可能有问题
-
参考
55. 虚拟机栈和本地方法栈会抛出OOM吗?
- 对于Hotspot虚拟机而言,虚拟机的栈容量是不能动态扩展的
- 因此,如果线程请求的栈深度大于虚拟机所允许的深度的话,会抛出StackOverflowError
- 但是不会抛出OutOfMemoryError
- 只有在创建线程的时候,由于内存不足才会抛出OutOfMemoryError,不过此时其实与栈关系不大
56. 运行时常量池会出现内存溢出问题吗?
- jdk1.7后,字符串常量池移动到了堆中
- 通过String.intern()方法能够往字符串常量池中填充数据,使得该区域溢出
67. 方法区会出现内存溢出问题吗?
-
方法区的垃圾回收比较严格
- 废弃常量
- 不再使用的类型
- 该类的所有实例都已经被回收
- 该类的类加载器已经被回收
- 该类的class对象在任何地方都没有被引用,无法通过反射访问该类
-
解决方法
- 可以通过设置JVM参数来调整元空间的大小
- -XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m
- 根据项目需求判断是否需要生成这么多的动态代理类
- 可以通过设置JVM参数来调整元空间的大小
68. 请介绍一个对象的过程?
- 类加载检查
- 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程
- 分配内存
- 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
- 初始化零值
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头
- 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
- 执行init方法
- 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, <init> ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ <init> ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
69. 请介绍一下Tomcat的类加载机制?
- Tomcat的WebAPPClassLoader类加载器违背了双亲委派机制
- 在加载类的时候,绕开了应用程序类加载器,直接委托给扩展类加载器
- 对于扩展类加载器和启动类加载器无法加载的类,会调用findClass方法加载web应用下的class
-
Common:会加载Tomcat路径下的lib文件下的所有类
-
Webapp1、Webapp2……:会加载webapp路径下项目中的所有的类。一个项目对应一个WebappClassLoader,这样就实现了应用之间类的隔离了
-
目的:
-
隔离不同的应用。避免不同的应用因为jar包的覆盖而无法启动
-
灵活性。不同的应用可以根据需要替换自己的类加载器
-
性能。多个应用中相同的类库可以交给common类加载器来加载
70. 打破双亲委派机制的风险有哪些?
- 有可能会破坏java类的统一性,导致数据不安全
- 破坏了双亲委派后,有可能加载被篡改过的同名核心类,这时候程序执行就会有问题(无法保证核心类还是那些类)
71. 能自定义java.lang.System/String类吗?
- 不行
- 双亲委派机制最终还是会加载顶层的string类
- 而且自定义的类加载器不能加载java.开头的类
- 在preDefineClass里面会抛异常
72. 双亲委派这个说法有问题吗
- 原文是parents delegate
- 父辈代理比较贴切,或者向上溯源
- 其实没有双亲,只有父类加载器的概念,而且也不是父类委派给子类,而是子类请求父类先尝试
- 实际上也不是继承关系
73. JDK8默认垃圾收集器
- Parallel Scavenge + Parallel Old
74. 默认升级年龄
- 15
75. 重载和重写在 JVM 层面是怎么鉴别的?
-
参考:https://blog.csdn.net/weixin_34161029/article/details/86396896
-
通过字节码指令来判断
- invokestatic:调用静态方法
- invokespecial:调用实例构造器方法,私有方法和父类方法。
- invokevirtual:调用所有的虚方法。
- 一般重写使用该指令
- invokeinterface:调用接口方***在运行时再确定一个实现此接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
-
前两条指令在类加载使其就可以将符号引用解析为直接引用,非虚方法
- 编译阶段就能确定要调用的方法,可以将符号引用解析为直接引用
-
后面的指令在编译阶段无法确定要调用的方法,在执行的时候需要判断对象的实际类型
-
方法的重载,其实在编译器阶段就已经确定好调用哪个方法,虚拟机在运行的时候是根据静态类型来决定要调用的方法
-
方法的重写,则是在运行时根据实际类型来决定
76. 方法内联是什么?
-
将方法体直接嵌入到调用函数的地方,节省函数调用带来的开销
-
条件
- 热点代码。JVM会统计一个方法的执行频率,对于热点代码会将代码编译成机器码并且进行内联
- 方法体不能太大。热点代码会保留在代码缓存池中,方法体太大,那么能缓存的方法就减少,性能下降
- 内联方法应该尽量保证被private、static、final修饰,因为这样jvm可以直接内联,否则jvm还需要判断内联的是父类还是子类的方法
77. 多态原理介绍一下?
-
首先,明确概念,JAVA中除了static方法、final方法(private方法属于)之外,其他的方法都是后期绑定(动态绑定)。
-
Java编译器将Java源代码编译成class文件,在编译过程中,会根据静态类型(当前引用变量的类型)将调用的符号引用写到class文件中。
-
在执行时,JVM根据class文件找到调用方法的符号引用,然后在静态类型的方法表(方法区中对应类的类型信息中可以找到方法表)中找到偏移量,然后根据this指针确定对象的实际类型(当前引用的实际对象类型),使用实际类型的方法表,偏移量跟静态类型中方法表的偏移量一样(单继承模型下,子类的方法表和父类方法表的顺序基本是一致的,子类新增的方***放在最后),如果在实际类型的方法表中找到该方法,则直接调用,否则,认为没有重写父类该方法。按照继承关系从下往上搜索
-
重点:
-
方法表是实现动态调用的核心。上面讲过方法表存放在方法区中的类型信息中。为了优化对象调用方法的速度,方法区的类型信息会增加一个指针,该指针指向一个记录该类方法的方法表,方法表中的每一个项都是对应方法的指针
-
这些方法中包括从父类继承的所有方法以及自身重写(override)的方法。
-
Java的方法调用
-
静态方法调用,静态绑定。invokestatic,invokespecial
-
动态方法调用,动态绑定。invokesvirtual 和 invokeinterface
- 可以看到,Girl 和 Boy 的方法表包含继承自 Object 的方法,继承自直接父类 Person 的方法及各自新定义的方法。注意方法表条目指向的具体的方法地址,如 Girl 继承自 Object 的方法中,只有 toString() 指向自己的实现(Girl 的方法代码),其余皆指向 Object 的方法代码;其继承自于 Person 的方法 eat() 和 speak() 分别指向 Person 的方法实现和本身的实现
- 方法表的偏移量在父子类之间是固定的,如果子类没有重写该方法,那么方法表中保存的就是指向父类方法的指针
-
在常量池(这里有个错误,上图为ClassReference常量池而非Party的常量池)中找到方法调用的符号引用 。
-
查看Person(父类类型)的方法表,得到speak方法在该方法表的偏移量(假设为15),这样就得到该方法的直接引用。
-
根据this指针得到具体的对象(即 girl 所指向的位于堆中的对象)。
-
根据对象得到该对象对应的方法表,根据偏移量15查看有无重写(override)该方法,如果重写,则可以直接调用(Girl的方法表的speak项指向自身的方法而非父类);如果没有重写,则需要拿到按照继承关系从下往上的基类(这里是Person类)的方法表,同样按照这个偏移量15查看有无该方法。
-
接口调用
-
由于Java允许多实现,所以接口方法调用的时候会更加麻烦一些,因为方法表的位置偏移量不再一致
-
Java 对于接口方法的调用是采用搜索方法表的方式,如,要在Dancer的方法表中找到dance()方法,必须搜索Dancer的整个方法表。
-
因为每次接口调用都要搜索方法表,所以从效率上来说,接口方法的调用总是慢于类方法的调用的
78. 反射原理介绍一下?
-
https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html
-
反射类及反射方法的获取,都是通过从列表中搜寻查找匹配的方法,所以查找性能会随类的大小方法多少而变化;
-
每个类都会有一个与之对应的Class实例,从而每个类都可以获取method反射方法,并作用到其他实例身上;
-
反射也是考虑了线程安全的,放心使用;
-
反射使用软引用relectionData缓存class信息,避免每次重新从jvm获取带来的开销;
-
反射调用多次生成新代理Accessor, 而通过字节码生存的则考虑了卸载功能,所以会使用独立的类加载器;
- 通过反射调用目标方法的时候,需要生成一个MethodAccessor的类,这个类是使用单独的类加载器进行加载的,目的是为了方便后续回收
-
当找到需要的方法,都会copy一份出来,而不是使用原来的实例,从而保证数据隔离;
-
调度反射方法,最终是由jvm执行invoke0()执行;
-
上面的Class对象是在加载类时由JVM构造的,JVM为每个类管理一个独一无二的Class对象,这份Class对象里维护着该类的所有Method,Field,Constructor的cache,这份cache也可以被称作根对象。
-
每次getMethod获取到的Method对象都持有对根对象的引用,因为一些重量级的Method的成员变量(主要是MethodAccessor),我们不希望每次创建Method对象都要重新初始化,于是所有代表同一个方法的Method对象都共享着根对象的MethodAccessor,每一次创建都会调用根对象的copy方法复制一份:
- 调用Method.invoke之后,会直接去调MethodAccessor.invoke。MethodAccessor就是上面提到的所有同名method共享的一个实例,由ReflectionFactory创建。
- 创建机制采用了一种名为inflation的方式(JDK1.4之后):如果该方法的累计调用次数<=15,会创建出NativeMethodAccessorImpl,它的实现就是直接调用native方法实现反射;如果该方法的累计调用次数>15,会由java代码创建出字节码组装而成的MethodAccessorImpl。(是否采用inflation和15这个数字都可以在jvm参数中调整) 以调用MyClass.myMethod(String s)为例,生成出的MethodAccessorImpl字节码翻译成Java代码大致如下。