JVM 面试题
JVM 面试题
1.JVM 的 TLAB 是什么?
TLAB(Thread-Local Allocation Bufer)是IM 中为每个线程分配的一小块堆内存,用于加速对象的分配操作。每个线程都有自己的 TLAB,,大大加速了内存分配的同时避免了多线程竞争共享堆内存时的同步开销。
工作原理:
- 每个线程在执行过程中优先从自己的 TLAB 中分配内存。
- 当 TLAB 中的内存耗尽时,线程会重新向 Eden 区申请一个新的 TLAB,或者直接从 Eden 区分配内存。
- 对象超过一定大小时(大对象),不会在 TLAB 中分配,而是直接在 Eden 区进行分配。
2.JVM 垃圾回收时产生的 concurrent mode failure 的原因是什么?
concurrent mode failure
是在Java 虚拟机使用 CMS(Concurrent Mark-sweep)
垃圾收集器 时的一种失败现象。当 CMS 在执行垃圾回收时,发现内存中的老年代(Old Generation)空间不足以继续分配新对象时,导致垃圾回收被迫转为 FuI GC。
产生的原因:
- CMS 收集器是并发进行的,意味着它会与应用线程同时运行。然而,如果在CMS 的并发回收阶段,还没有及时清理出足够的空间来满足新对象分配,就会出现
concurrent mode failure
。 - 一旦发生
concurrent mode failure
,JM 会停止应用线程,进入 Fu GC 以回收更多内存,显著影响程序性
能。
3.Java 是如何实现跨平台的?
编译执行:是指程序在执行之前,首先通过编译器将源代码编译为机器代码,然后直接在 CPU 上运行。常见的编译语言如 C、C++。
- 优点:编译后的程序运行速度快,因为机器代码是针对目标平台直接生成的,且不需要在运行时再进行翻译。
- 缺点:程序必须针对每个平台重新编译,跨平台性差;另外,编译后生成的机器代码难以调试和逆向工程。
解释执行:解释执行是指源代码不经过编译器的预先编译,而是在运行时通过解释器逐行翻译并执行。常见的解释
语言如 Python、Ruby。
- 优点:跨平台性好,因为代码在每个平台上都是通过相应平台的解释器来运行的,且开发周期更短。
- 缺点:运行速度较慢,因为每次执行时都需要进行动态翻译和解释。
JVM 采用编译执行和解释执行相结合的方式:
- 解释执行:JVM 会逐行解释执行字节码,尤其是程序初次运行时,这种方式有助于程序的跨平台性。
- 即时编译(JT):JVM 引入了即时编译器(Just-In-Time Compiler),在程序运行时将热代码(经常执行的代码)编译为本地机器码,避免反复解释,提升性能。因此,M 实际上是混合使用解释执行和编译执行。
4.JVM 内存区域是如何划分的?
JVM 运行时数据区分为方法区、堆、虚拟机栈、本地方法栈、程序计数器
1)方法区(Method Area):
- 存储类信息、常量、静态变量和即时编译器(JT)编译后的代码。
- 属于线程共享区域,所有线程共享方法区内存。
- 在JDK8之前,HotSpot使用永久代(PermGen).来实现方法区,JDK8之后被*元空间(Metaspace)*取代,元空间使用的是直接内存(Native Memory)。
2)堆(Heap):
- 用于存放所有线程共享的对象和数组,是垃圾回收的主要区域。
3)虚拟机栈(JVM Stack):
- 每个线程创建一个栈,用来保存局部变量、操作数栈、动态链接、方法出口信息等。
- 局部变量表中存储的是基本数据类型(如int、float)以及对象引用。
- 栈是线程私有的,生命周期与线程相同。
4)本地方法栈(Native Method Stack):
- 为本地方法服务,使用JNl(Java Native Interface)调用的本地代码在此区域分配内存。
- 和虚拟机栈类似,也是线程私有的。
5)程序计数器(Program Counter Register):
- 是一个小的内存区域,保存当前线程执行的字节码指令的地址或行号。
- 每个线程都有一个独立的程序计数器,属于线程私有。
5.Java 中堆和栈的区别是什么?
栈(Stack):主要用于存储局部变量和方法的调用信息(如返回地址、参数等)。在方法执行期间,局部变量(包括引用变量,但不包括它们引用的对象)被创建在栈上,并在方法结束时被销毁。
堆(Heap):用于存储对象实例和数组。每当使用 new 关键字创建对象时,IM 都会在堆上为该对象分配内存空间。
- 从其他方面进一步区分:
生命周期:我们知道 JVM 里面的垃圾回收主要是对堆空间的处理,而空间是不会被回收的,所以栈空间的生命周期都非常的短,比如一次方法的调用,调用的时候存入,执行完成就被弹出释放。而堆空间是需要通过GC进行回收的。所以堆空间的数据生命周期会相对较长! - 空间大小:栈的空间大小都是固定的,根据操作系统决定,如果是64位的则大小为8个字节。但是堆的空间大小并不确定,根据对象的大小进行一个划分
特别注意,如果定义的变量是一个基本数据类型,比如 inta=10,这个时候并不会分配堆内存,10 会直接存在栈空间。
如果是引用数据类型,比如Aa=new A();这种a分配到栈空间是一个地址,指向堆中的实例化的A。
如果A中定义了一个属性 Bb=new B();这个b并不会存在栈空间,而是直接放在堆空间,存储的是事例化的B的地址!
6.什么是 Java 中的直接内存?
我们启动 JVM 的时候都会设置堆的大小,而直接内存占用的是堆外的内存,它不属于堆。
理论上我们在 Java 中想要操作堆外的内存,需要将其拷贝到堆内,因此 Java 弄了个 Direct Memory,它允许 Java 访问原生内存(非堆内内存),这样就减少了堆外到堆内的这次拷贝,提升 10 效率,在文件读写和网络传输场景直接内存有很大的优势。
不过堆外内存不归 JM 设置的堆大小限制(在 JM 中可以利用 -XX:MaxDirectMemorySize 参数设置直接内存的最大值),且不受垃圾回收器管理,因此在使用上需要注意直接内存的释放,防止内存泄漏。
在 Java 中可以利用 Unsafe 类和 NIO 类库使用直接内存。
例如利用 NIO 的 ByteBuffer.allocateDirect(1024)即可分配得到一个直接内存。
1 | import java.nio.ByteBuffer; |
注意最后一行释放内存的 cleaner。因为垃圾回收器无法直接管理堆外内存,所以JM 在创建 ByteBuffer 的时候,在堆内存储了这个对象的指针,然后注册了一个关联的 cleaner(清理器)。
7.什么是 Java 中的常量池?
常量池其实就是方法区的一部分,全称应该是运行时常量池(runtime constant pool)),主要用于存储字面量和符号引用等编译期产生的一些常量数据。
比如一些字符串、整数、浮点数都是字面量,源代码中一个写了一个固定的值的都叫字面量。
比如你代码写了一个String s=’aa’;那么aa就是字面量,存储在常量池当中。
符号引用指的是字段的名称、接口全限定名等等,这些都算符号引用。
常量池的好处是减少内存的消耗,比如同样的字符串,常量池仅需存储一份。
且常理池在类加载后就已经准备好了,这样程序运行时可以快速的访问这些数据,提升运行时的效率。
不过在Jva1.7的时候,HotSpot将字符串从运行时常量池(方法区内)中剥离出来,搞了个字符串常量池存储在堆
内,因为字符串对象也经常需要被回收,因此放置到堆中好管理回收。
不过按照 Java 虚拟机定义而言,字符串常量池还是属于运行时常量池,只不过 Hotspot 的实现将其放在里堆中而已,逻辑上它还是属于运行时常量池。
8.Java 类加载器是什么?
我们平常写的代码是保存在一个 java文件里面,经过编译会生成.class文件,这个文件存储的就是字节码,如果要用上我们的代码,那就必须把它加载到 JVM 中。
而类加载的步骤主要分为:加载、链接、初始化。
加载阶段,需要用到类加载器来将 class 文件里面的内容加载到 IM 中生成类对象。
JDK8 的时候一共有三种类加载器:
1)启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,用C++ 实现的(JDK9 后用 java 实现),主要负责加载
2)扩展类加载器(Extension ClassLoader),它是Java 实现的,独立于虚拟机,主要负责加载
3)应用程序类加载器(Application ClassLoader),它是Java 实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这个加载器就是我们程序中的默认加载器。
在 JDK9 之后,类加载器进行了一些修改,主要是因为 JDK9 引入了模块化,即 Jigsaw,原来的 tjar、tool.jar 等都被拆成了数十个jmod文件,已满足可扩展需求,无需保留
台类加载器(PlatformClassLoader)),主要加载被module-info.java中定义的类。
且双亲委派的路径也做了一定的变化:
在平台和应用类加载器受到加载请求时,会先判断该类是否属于一个系统模块,如果属于则委派给对应的模块类加载器加载,反之才委派给父类加载器。
JDK9 之后类加载器负责模块(图来自网络):
9.什么是 Java 的逃逸分析?
逃逸分析其实是一种优化,它用于确定对象是否可以被限定在某个方法或线程中而不会被其它部分的代码引用。
正常情况下我们分配对象都是在堆上,而堆需要内存管理,即垃圾回收。但假设,这个对象不可能会被外部访问,只会在当前的线程内被访问,那么是不是可以分配在栈上?分配在栈上的变量会随着方法的结束而自动销毁,这样就可以减少 GC 工作。
所以逃逸分析就来分析一个对象是否会被外部或其他线程访问,用来优化对象的分配。同理如果不会被外部引用,也可以做同步消除,即消除该对象的同步锁,减少锁带来的性能开销。
还可以将对象拆解为基本类型,直接在局部变量中维护。
因此它的作用是:
1)栈上分配
2)同步消除
3)标量替换
JVM 默认会开启这项优化,逃逸分析是 JT 编译的一部分,在 JT 编译时就会进行这项优化。
10.Java 中的强引用、软引用、弱引用和虚引用分别是什么?
Java 根据其生命周期的长短将引用类型又分为强引用、软引用、弱引用、幻象引用。
- 强引用:就是我们平时 new 一个对象的引用。当M 的内存空间不足时,宁愿抛出 OutOfMemoryError 使得程序异常终止,也不愿意回收具有强引用的存活着的对象。
- 弱引用:比软引用还短,在 GC的时候,不管内存空间足不足都会回收这个对象,ThreadLocal中的 key 就用到了弱引用,适合用在内存敏感的场景。
- 软引用:生命周期比强引用短,当M 认为内存空间不足时,会试图回收软引用指向的对象,也就是说在 JVM抛出 OutOfMemoryError 之前,会去清理软引用对象,适合用在内存敏感的场景。
- 虚引用:也称幻象引用,之所以这样叫是因为虚引用的 get 永远都是 null ,称为 get 了个空虚,所以叫虚。
虚引用的唯一作用就是配合引用队列来监控引用的对象是否被加入到引用队列中,也就是可以准确的让我们知晓对象何时被回收。
11.Java 中常见的垃圾收集器有哪些?
1)Serial 收集器
Serial收集器是最基础、历史最悠久的收集器,它是一个单线程收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,这是其主要缺点。
它的优点在于单线程避免了多线程复杂的上下文切换,因此在单线程环境下收集效率非常高,由于这个优点,迄今为止,其仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器:
2)ParNew 收集器
它是 Serial 收集器的多线程版本,可以使用多条线程进行回收:
3)Parallel Scavenge 收集器
Paralel Scavenge 也是新生代收集器,基于 标记-复制 算法进行实现,它的目标是达到一个可控的吞吐量。这里的吞吐量指的是处理器运行用户代码的时间与处理器总消耗时间的比值
1 | 吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间) |
Parallel Scavenge 收集器提供两个参数用于精确控制吞吐量:
1)-XX:MaxGCPauseMiis:控制最大垃圾收集时间,假设需要回收的垃圾总量不变,那么降低垃圾收集的时间就会导致收集频率变高,所以需要将其设置为合适的值,不能一味减小。
2)-XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于0小于 100 的整数。假设把它设置为 19,表示比时允许的最大垃圾收集时间占总时间的5%(即 1/(1+19));默认值为 99,即允许最大1%(1/(1+99))的垃圾收集时间。
4)Serial Old 收集器
从名字也能看出来,它是 Serial 收集器的老年代版本,同样是一个单线程收集器,采用 标记-整理 算法,主要用于给客户端模式下的 HotSpot 使用:
5)Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,采用 标记-整理 算法实现:
6)CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于 标记-清除 算法实现,整个收集过程分为以下四个阶段:
1.初始标记 (inital mark):标记 GC Roots 能直接关联到的对象,耗时短但需要暂停用户线程;2.并发标记(concurrent mark):从 GC Roots 能直接关联到的对象开始遍历整个对象图,耗时长但不需要暂停用
户线程;
3.重新标记 (remark):采用增量更新算法,对并发标记阶段因为用户线程运行而产生变动的那部分对象进行重新标记,耗时比初始标记稍长且需要暂停用户线程;
4.并发清除 (inital sweep) :并发清除掉已经死亡的对象,耗时长但不需要暂停用户线程。
其优点在于耗时长的 并发标记 和 并发清除 阶段都不需要暂停用户线程,因此其停顿时间较短,其主要缺点如下:
由于涉及并发操作,因此对处理器资源比较敏感。
由于是基于 标记-清除 算法实现的,因此会产生大量空间碎片。
无法处理浮动垃圾(floating Garbage):由于并发清除时用户线程还是在继续,所以此时仍然会产生垃圾,这些垃圾就被称为浮动垃圾,只能等到下一次垃圾收集时再进行清理。
7)G1 收集器
G1(Garbage-frist)收集器是一种面向服务器的垃圾收集器,主要应用在多核 CPU 和 大内存的服务器环境中。
G1虽然也遵循分代收集理论,但不再以固定大小和固定数量来划分分代区域,而是把连续的Java堆划分为多个大小相等的独立区域(Region)。每一个 Region 都可以根据不同的需求来扮演新生代的 Eden 空间、Survivor 空间或者老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略。
G1 收集器的运行大致可以分为以下四个步骤:
1)初始标记(lnital Marking):标记 GC Roots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象。
G1为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围;
2)并发标记(Concurrent Marking):从 GC Roots 能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象。
SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高;
3)最终标记(final Marking):对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的 STAB记录。虽然并发标记阶段会处理 SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理;
4)筛选回收 (live Data Counting and Evacuation):负责更新 Region 统计数据,按照各个 Region 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Reqion 构成回收集。
然后将回收集中 Reqion 的存活对象复制到空的 Region 中,再清理掉整个旧的 Region。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。
12.Java 中如何判断对象是否为垃圾?不同垃圾回收方法有何不同?
共有两种方法:引用计数器和可达性分析。
引用计数有循环依赖的问题,但是是可以解决的。
可达性分析则是从根引用(GCRoots)开始进行引用链遍历扫描,如果可达则对象存活,如果不可达则对象已成为垃圾。
所谓的根引用包括全局变量、栈上引用、寄存器上的等。
引用计数器
引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器减少为0的时候就意味着这个单元再也无法被引用了,所以可以立即释放内存。
如上图所示,云朵代表引用,此时对象A有1个引用,因此计数器的值为 1。对象B有两个外部引用,所以计数器的值为2,而对象C没有被引用,所以说明这个对象是垃圾,因此可以立即释放内存。
由此可以知晓引用计数需要占据额外的存储空间,如果本身的内存单元较小则计数器占用的空间就会变得明显。其次引用计数的内存释放等于把这个开销平摊到应用的日常运行中,因为在计数为0的那一刻,就是释放的内存的时刻,这其实对于内存敏感的场景很适用。
如果是可达性分析的回收,那些成为垃圾的对象不会立马清除,需要等待下一次 GC才会被清除。
引用计数相对而言概念比较简单,不过缺陷就是上面提到的循环引用。
可达性分析
可达性分析其实就是利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。至于用什么方式清,清了之后要不要整理这都是后话。
标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。
所谓的根引用包括全局变量、栈上引用、寄存器上的等。
13.Java 中有哪些垃圾回收算法
常见的就是:标记-复制、标记-清除、标记整理。
标记-清除
标记-清除算法应该是最符合我们人一开始处理垃圾的思路的算法。例如,我们想清除房间的垃圾,我们肯定是先定位(对应标记)哪些是垃圾,然后把这些垃圾之后扔了(对应清除),简单粗暴,剩下的不是垃圾的东西我也懒得理,不管了哈哈哈。
但是,这算法有个缺点:
空间碎片问题,这样会使得比较大的对象要申请比较多的连续空间的时候申请不到,明明你空间还很足的。然后导致又一次 GC
复制算法
复制算法一般用于新生代,粗暴的复制算法就是把空间一分为二,然后将一边存活的对象复制到另一边,这样没有空间碎片问题,但是内存利用率太低了,只有 50%,所以 HotSpot 中是把一块空间分为3块,一块 Eden,两块
Survivoror
因为正常情况下新生代的大部分对象都是短命鬼,所以能活下来的不多,所以默认的空间划分比例是 8:1:1。
用法就是每次只使用 Eden 和一块 Sunvivor,然后把活下来的对象都扔到另一块 Sunvivor。再清理 Eden 和之前的那块 Survivor
最后再把 den 和存放存活对象的那一块 Suryivor 用来迎接新的对象,等于每次回收了之后都会对调一下两个Survivoror
标记-整理算法
标记-整理算法的思路也是和标记-清除算法一样,先标记那些需要清除的对象,但是后续步骤不一样,它是整理,对就是像上面说的那些清除房间垃圾每次都会整理的人一样那么勤劳。
每次会移动所有存活的对象,且按照内存地址次序依次排列,也就是把活着的对象都像一端移动,然后将末端内存地址以后的内存全部回收。所以用了它也就没有空间碎片的问题了。
14.为什么 Java 的垃圾收集器将堆分为老年代和新生代?
因为不同对象的生命周期不一样,大部分对象朝生夕死,而少部分一直存在堆中,所以按照存活时间分区管理更加高效。
也因为不同分区的生命周期不同,所以可以采用不同的清除算法来优化处理,像新生代的对象”死亡率”比较高,因此标记复制比较合适(大部分对象都消失了,把存活的复制到一边,死亡的全部清理即可)
而老年代的对象存活时间比较长,因此标记清除即可(存活对象比较多,整理或复制耗时比较长)且分区后可以减少 GC 暂停的时间,你想想每次处理一个堆的数据,还是将堆分区处理来的快?
总而言之,分区是为了更高效地管理不同生命周期的对象。
15.什么是三色标记算法?
三色标记算法,主要来区分 GC对象被扫描过的情况:
白色:表示还未搜索到的对象。
灰色:表示正在搜索还未搜索完的对象。
黑色:表示搜索完成的对象。
GC 开始前所有对象都是白色,GC一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
16.Java 中的 young GC、old GC、full GC 和 mixed GC 的区别是什么?
其实 GC 分为两大类,分别是 Partial GC 和 Full GC。
Partial GC 即部分收集,分为 young gc、old gc、mixed gc。
- young gc:指的是单单收集年轻代的 GC。
- old gc:指的是单单收集老年代的 GC。
- mixed gc:这个是 G1 收集器特有的,指的是收集整个年轻代和部分老年代的 GC。
Ful GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代。
其实还有 Maior Gc 这个名词,在《深入理解Java虚拟机》中这个名词指代的是单单老年代的 GC,也就是和 old gc等价的,不过也有很多资料认为其是和 full gc 等价的。
还有 Minor GC,其指的就是年轻代的 gc。
17.什么条件触发 Java 的 young GC 和 Full GC?
young GC 触发条件:
大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc。
为什么要说大致上呢?因为有一些收集器的回收实现是在 full gc前会让先执行以下 young gc。
比如 Parallel Scavenge,不过有参数可以调整让其不进行 young gc。
可能还有别的实现也有这种操作,不过正常情况下就当做 eden 区快满了即可。
eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB 分配内存不够。
Full GC 触发条件:
- 在要进行 young gc 的时候,根据之前统计数据发现年轻代平均晋升大小比现在老年代剩余空间要大,那就会触发 full gc。
- 有永久代的话如果永久代满了也会触发 full gc。
- 老年代空间不足,大对象直接在老年代申请分配,如果此时老年代空间不足则会触发 ful gc。
- 担保失败即 promotion failure,新生代的 to 区放不下从 eden 和 from 拷贝过来对象,或者新生代对象 qc 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 full gc。
- 执行 System.gc0)、jmap -dump 等命令会触发 full gc。
18.为什么 Java 中 CMS 垃圾收集器在发生 Concurrent Mode Failure 时的FuII GC 是单线程的?
因为没足够开发资源,偷懒了。
19.对象创建和销毁的过程了解吗?
当我们使用 new 关键字创建一个对象的时候,JVM 首先会检查 new 指令的参数是否能在常量池中定位到一个类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就先执行相应的类加载过程。
如果已经加载,JVM 会为新生对象分配内存,内存分配完成之后,JVM 将分配到的内存空间初始化为零值(成员变量,数值类型是 0,布尔类型是 false,对象类型是 null),接下来设置对象头,对象头里包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息。
最后,JVM 会执行构造方法(<init>
),将成员变量赋值为预期的值,这样一个对象就创建完成了。
对象的销毁过程了解吗?
对象创建完成后,就可以通过引用来访问对象的方法和属性,当对象不再被任何引用指向时,对象就会变成垃圾。
垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。
垃圾收集器会通过标记清除、标记复制、标记整理等算法来回收内存,将对象占用的内存空间释放出来。
常用的垃圾收集器有 CMS、G1、ZGC 等,它们的回收策略和效率不同,可以根据具体的场景选择合适的垃圾收集器。
20.JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?
会,假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。
采用 CAS 分配重试的方式来保证更新操作的原子性
每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲(Thread Local Allocation
Buffer,TLAB),要分配内存的线程,先在本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
21.能说一下对象的内存布局吗?
在 Java 中,对象的内存布局是由 Java 虚拟机规范定义的,但具体的实现细节可能因不同的 JVM 实现(如 HotSpot、OpenJ9 等)而异。
在 HotSpot 中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。
①、对象头是每个对象都有的,包含三部分主要信息:
- 标记字(Mark Word):包含了对象自身的运行时数据,如哈希码(HashCode)、垃圾回收分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等信息。在 64 位操作系统下占 8 个字节,32 位操作系统下占 4 个字节。
- 类型指针(Class Pointer):指向对象所属类的元数据的指针,JVM 通过这个指针来确定对象的类。在开启了压缩指针的情况下,这个指针可以被压缩。在开启指针压缩的情况下占 4 个字节,否则占 8 个字节。
- 数组长度(Array Length):如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。
注意,启用压缩指针(-XX:+UseCompressedOops
)可以减少对象头中类型指针的大小,从而减少对象总体大小,提高内存利用率。
可以通过 java -XX:+PrintFlagsFinal -version | grep UseCompressedOops
命令来查看当前 JVM 是否开启了压缩指针。
如果压缩指针开启,会看到类似以下的输出,其中 bool UseCompressedOops 的值为 true。
在 JDK 8 中,压缩指针默认是开启的,以减少 64 位应用中对象引用的内存占用。
②、实例数据存储了对象的具体信息,即在类中定义的各种字段数据(不包括由父类继承的字段)。这部分的大小取决于对象的属性和它们的类型(如 int、long、引用类型等)。JVM 会对这些数据进行对齐,以确保高效的访问速度。
③、对齐填充,为了使对象的总大小是 8 字节的倍数(这在大多数现代计算机体系结构中是最优访问边界),JVM 可能会在对象末尾添加一些填充。这部分是为了满足内存对齐的需求,并不包含任何具体的数据。
22.内存溢出和内存泄漏是什么意思?
内存溢出(Out of Memory,俗称 OOM)和内存泄漏(Memory Leak)是两个不同的概念,但它们都与内存管理有关。
①、内存溢出:是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。在 Java 中,这种情况会抛出 OutOfMemoryError。
内存溢出可能是由于内存泄漏导致的,也可能是因为程序一次性尝试分配大量内存,内存直接就干崩溃了导致的。
②、内存泄漏:是指程序在使用完内存后,未能释放已分配的内存空间,导致这部分内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。
用一个比较有味道的比喻来形容就是,内存溢出是排队去蹲坑,发现没坑了;内存泄漏,就是有人占着茅坑不拉屎,占着茅坑不拉屎的多了可能会导致坑位不够用。
23.内存泄漏可能由哪些原因导致呢?
24.finalize()方法了解吗?有什么作用?
用一个不太贴切的比喻,垃圾回收就是古代的秋后问斩,finalize()就是刀下留人,在人犯被处决之前,还要做最后一次审计,青天大老爷看看有没有什么冤情,需不需要刀下留人。
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。如果对象在在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它就”逃过一劫“;但是如果没有抓住这个机会,那么对象就真的要被回收了。
25.Java 堆的内存分区了解
按照垃圾收集,将 Java 堆划分为新生代 (Young Generation)和老年代(Old Generation)两个区域,新生代存放存活时间短的对象,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
而新生代又可以分为三个区域,eden、from、to,比例是 8:1:1,而新生代的内存分区同样是从垃圾收集的角度来分配的。
26.对象啥时候步入老年代?
对象通常会现在年轻代中分配,然后随着时间的推移和垃圾收集的处理,某些对象会进入到老年代中。
①、长期存活的对象将进入老年代
对象在年轻代中存活足够长的时间(即经过足够多的垃圾回收周期)后,会晋升到老年代。
每次 GC 未被回收的对象,其年龄会增加。当对象的年龄超过一个特定阈值(默认通常是 15),它就会被移动到老年代。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold
来设置。
②、大对象直接进入老年代
为了避免在年轻代中频繁复制大对象,JVM 提供了一种策略,允许大对象直接在老年代中分配。
这些是所谓的“大对象”,其大小超过了预设的阈值(由 JVM 参数-XX:PretenureSizeThreshold
控制)。直接在老年代分配可以减少在年轻代和老年代之间的数据复制。
③、动态对象年龄判定
除了固定的年龄阈值,还会根据各个年龄段对象的存活大小和总空间等因素动态调整对象的晋升策略。
27.什么是双亲委派模型?
双亲委派模型(Parent Delegation Model)是 Java 类加载机制中的一个重要概念。这种模型指的是一个类加载器在尝试加载某个类时,首先会将加载任务委托给其父类加载器去完成。
只有当父类加载器无法完成这个加载请求(即它找不到指定的类)时,子类加载器才会尝试自己去加载这个类。
- 当一个类加载器需要加载某个类时,它首先会请求其父类加载器加载这个类。
- 这个过程会一直向上递归,也就是说,从子加载器到父加载器,再到更上层的加载器,一直到最顶层的启动类加载器(Bootstrap ClassLoader)。
- 启动类加载器会尝试加载这个类。如果它能够加载这个类,就直接返回;如果它不能加载这个类(因为这个类不在它的搜索范围内),就会将加载任务返回给委托它的子加载器。
- 子加载器接着尝试加载这个类。如果子加载器也无法加载这个类,它就会继续向下传递这个加载任务,依此类推。
- 这个过程会继续,直到某个加载器能够加载这个类,或者所有加载器都无法加载这个类,最终抛出 ClassNotFoundException。
28.为什么要用双亲委派模型?
可以为 Java 应用程序的运行提供一致性和安全性的保障。
①、保证 Java 核心类库的类型安全
如果自定义类加载器优先加载一个类,比如说自定义的 Object,那在 Java 运行时环境中就存在多个版本的 java.lang.Object,双亲委派模型确保了 Java 核心类库的类加载工作由启动类加载器统一完成,从而保证了 Java 应用程序都是使用的同一份核心类库。
②、避免类的重复加载
在双亲委派模型中,类加载器会先委托给父加载器尝试加载类,这样同一个类不会被加载多次。如果没有这种模型,可能会导致同一个类被不同的类加载器重复加载到内存中,造成浪费和冲突。
29.如何破坏双亲委派机制?
如果不想打破双亲委派模型,就重写 ClassLoader 类中的 fifindClass()方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。而如果想打破双亲委派模型则需要重写 loadClass()方法。