JVM
说明
v1.0 2024/10/24
jvm是在整个八股背的阶段老是记得不熟
感觉这部分内容以后工作的重点,现在我觉得暂时了解如下即可,以后再补充
v1.1 2025/4/28
追加内容,多听多看
常见的垃圾清理方法
标记清除算法,复制算法,标记整理算法,分代算法
常见的垃圾收集器:
Serial GC, Parallel GC, CMS GC(Concurrent Mark Sweep), G1 GC(Garbage First), ZGC
JVM的内存模型介绍一下
- 元空间:元空间的本质和永久代类似,都是对IM规范中方法区的实现。不过元空间与永久代之间最大的区别在于元空间并不在虚拟机中,而是使用本地内存。
- Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧"的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
- 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行Java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
- 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有“内存。
- 堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是JVM 内存占用最大,管理最复杂的一个区域。其唯一的用途就是存放对象实例:所有的对象实例及数组都在对上进行分配。jdk1.8后,字符串常量池从永久代中剥离出来,存放在堆中。
如何判断对象是否可以被回收?
引用计数法和可达性分析法是两种不同的内存管理和垃圾回收算法。引用计数法通过维护引用计数器来跟踪对象的引用数量,具有实时性好、简单高效等优点,但存在循环引用等问题;而可达性分析法则通过分析对象的引用关系来判断对象是否可达,从而决定对象是否可以被回收,具有准确性高、效率好等优点,是 JVM 中常用的垃圾回收算法之一
详细
引用计数法
引用计数法(Reference Counting)是一种内存管理技术,用于跟踪对象的引用数量。每个对象都有一个引用计数器,记录着指向该对象的引用数量。
当一个对象被引用时,引用计数器加一;当一个引用被释放时,引用计数器减一。当引用计数器为零时,表示没有任何引用指向该对象,该对象可以被释放,回收其占用的内存。
优点:
实时性好:当没有引用指向一个对象时,该对象可以立即被回收,释放内存资源
简单高效:引用计数法是一种相对简单的内存管理技术,实现起来较为高效。
无需沿指针查找:与GC标记-清除算法不同,引用计数法无需从根节点开始沿指针查找。
缺点:
循环引用问题:当存在循环引用的情况下,对象之间的引用计数可能永远不会为零,导致内存泄漏的发生
额外开销:每个对象都需要维护一个引用计数器,这会带来一定的额外开销。
不支持并发:在多线程环境下,引用计数法需要进行额外的同步操作,以确保引用计数的准确性,可能导致性能损失。
可达性分析法
通过分析对象的引用关系,判断对象是否可达,从而决定是否可以被回收。可达性分析算法是JVM垃圾回收中的一种算法。
工作原理
- GC Roots:在Java中,GC Roots通常包括虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区(静态变量)中引用的对象、本地方法栈中JNI(Native方法)引用的对象等。
- 搜索过程:可达性分析算法从GC Roots开始,递归地访问所有可达的对象,并给它们打上标记。这个过程可以使用深度优先搜索(DFS)或广度优先搜索(BFS)等图遍历算法来实现。
- 回收判定:如果一个对象到GC Roots没有任何引用链相连(即该对象从GC Roots不可达),则证明该对象是不可用的,可以判定为可回收对象。
特点
- 准确性:通过从GC Roots开始搜索引用链,可以准确地判断哪些对象是可回收的。
- 效率:结合现代 JVM 的优化技术,如增量标记、并发标记等,可以提高可达性分析算法的效率。
- 灵活性:可达性分析算法可以与不同的垃圾回收策略(如标记-清除、标记-整理等)结合使用,以适应不同的应用场景和硬件环境。
JVM内存模型中的堆和栈有什么区别
- 用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用 new 关键字创建一个对象时,对象的实例就会在堆上分配空间。
- 生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Colection,GC)检测到对象不再被引用时才被回收。
- 存取速度:栈的存取速度通常比堆快,栈操作简单快速。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
- 存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大;堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
栈中存的到底是指针还是对象?
在JM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。
当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如int,double等)和对象的引用,而不是对象本身。
这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如Myobject obj= new Myobject();
,这里的 obj 实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。
堆分为哪几个部分
- 新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中,大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次MinorGc(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor1)。在每次Minor GC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(Old Generation/Tenured Generation):一次或多次Minor GC后仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major Gc(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存这解决了永久代容易出现的内存溢出问题。
- 大对象区(Large Object Space/Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
方法区中的方法的执行过程?
当程序中通过对象或类调用某个方法时的步骤如下:
解析方法调用:JVM会根据方法的符号引用找到实际的方法地址(如果之前没有解析过的话)。
栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
执行方法:执行方法内的字节码指令,涉及的操作可能包括局部变量的读写、操作数栈的操作、跳转控制、对象创建、方法调用等。
返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
方法区中有哪些东西
String保存在字符串常量池中
方法区:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器(JIT)编译后的代码缓存等
- 类信息:包括类的结构信息、类的访问修饰符、父类与接口等信息。
- 常量池:存储类和接口中的常量,包括字面值常量、符号引用,以及运行时常量池。
- 静态变量:存储类的静态变量,这些变量在类初始化的时候被赋值。
- 方法字节码:存储类的方法字节码,即编译后的代码。
- 符号引用:存储类和方法的符号引用,是一种直接引用不同于直接引用的引用类型
- 运行时常量池:存储着在类文件中的常量池数据,在类加载后在方法区生成该运行时常量池。
- 常量池缓存:用于提升类加载的效率,将常用的常量缓存起来方便使用。
String s = new String(“abc”) 执行过程中创建多少个对象
Strings= new String(“abc”)
首先,new指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。
其次,在String的构造方法中传递了一个字符串abc,由于这里的abc是被final修饰的属性,所以它是一个字符串常量。在首次构建这个对象时,JVM拿字面量“abc”去字符串常量池试图获取其对应String对象的引用。于是在堆中创建了一个"abc”的String对象,并将其引用保存到字符串常量池中,然后返回;
所以,如果abc这个字符串常量不存在,则创建两个对象,分别是abc这个字符串常量,以及new string这个实例对象。如果abc这字符串常量存在,则只会创建一个对象。
引用类型有哪些?有什么区别?
- 强引用指的就是代码中普遍存在的赋值方式,比如用new关键字创建的对象实例。强引用关联的对象,永远不会被GC回收。
- 软引用可以用
SoftReference
来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。 - 弱引用可以用
WeakReference
来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够 - 虚引用也被称作幻影引用,是最弱的引用关系,可以用
PhantomReference
来描述,他必须和ReferenceQueue
(对应一个引用队列,发生GC的时候虚引用被回收,这时引用队列的计数器+1表示这个虚引用被GC掉了)一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
内存泄漏和内存溢出的理解
内存泄漏:就是说程序中本该被清理掉的对象仍然在内存空间中存在而无法被回收,并且越来越多导致可用空间逐渐减小(使用静态数据结构比如HashMap或ArrayList存储对象且未清理、为取消对事件的监听、未停止的线程)
内存溢出:就是说像JVM申请内存空间时无法找到足够大的内存空间,最终引发OOM(深度递归,持久引用,大量对象创建)
创建对象的过程
在Java中创建对象的过程包括以下几个步骤:
- 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java 堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的值。
- 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java 程序的视角看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其地资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
类加载器
启动类加载器(Bootstrap class Loader):这是最顶层的类加载器,负责加载Java的核心库(如位于jre/lib/rt,jar中的类),它是用C++编写的,是JVM的一部分。启动类加载器无法被Java程序直接引用。
扩展类加载器(Extension Class Loader):它是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录(jre/lib/ext或由系统变量java.ext.dirs指定的目录)下的jar包和类库。扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。
系统类加载器(System Class Loader)/应用程序类加载器(Application Class Loader):这也是Java语言实现的,负责加载用户类路径(ClassPath)上的指定类库,是我们平时编写Java程序时默认使用的类加载器。系统类加载器的父加载器是扩展类加载器。它可以通过ClassLoader.getSystemClassLoader()方法获取到。
自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络加载class文件,数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是)Java动态性的一个重要体现。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派的作用
- 保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
- 保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
- 支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
- 简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。
类加载过程
类从被加载到虚拟机内存开始到卸载出内存为止整个生命周期包括上图阶段
- 加载:通过类的全限定名(包名+类名),获取该类的
.class
文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class
对象,作为方法区这个类的各种数据访问入口 - 连接(验证、准备、解析三个阶段统称为【连接】):
- 验证:确保class文件中的字节流包含的信息符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
- 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用,那引用的目标必定已经存在于内存中了。
- 初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(init),要注意的是这里的构造器方法(init)并不是开发者写的,而是编译器自动生成的。
- 使用:使用类或者创建对象(类加载之后就能确定创建该类的实例对象要分配多大内存空间)
- 卸载:如果有下面的情况,类就会被卸载
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾回收机制
垃圾回收何时触发?
- 内存不足时:检测到堆内存不足,无法为新的对象分配内存,会自动触发垃圾回收。
- 手动请求:虽然垃圾回收是自动的,开发者可以通过调用system.gc()或Runtime.getRuntime().gc()建议JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。
- JVM参数:启动应用时可以通过 JVM 参数来调整垃圾回收的行为,比如:
-Xmx
(最大堆大小)、-Xms
(初始堆大小)等。 - 对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收。
垃圾分类
主要依赖两种主流的垃圾回收算法——引用计数法、可达性分析法
引用计数法
原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。计数器为0时,表示对象不再被任何变量引用,可以被回收。
缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收(虽然思想简单,但是实现起来比较复杂,而且也可能有线程问题)
可达性分析算法
垃圾回收器会从堆的根节点(如程序计数器、虚拟机栈、本地方法栈和方法区中的类静态属性等),即gcroot开始遍历对象图,标记所有可以到达的对象为存活对象,未被标记的对象则被认为是垃圾对象。
垃圾回收算法
- 标记-清除算法:标记-清除算法分为“标记“和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间,有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
- 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题:每次在申请内存时都只能使用一半的内存空间,内存利用率严重不足。
- 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记“过程与“标记-清除算法"的标记过程一致,但标记之后不会直接清理,而是将所有存活对象都移动到内存的一端,移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的GC次数。对象创建时,一般在新生代申请内存,当经历一次 GC之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -Xx:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。
minorGC\majorGC\fullGC的区别
Minor Gc (Young GC)
作用范围:只针对年轻代进行回收,包括Eden区和两个Survivor区(S0和S1)
触发条件:当Eden区空间不足时,JVM会触发一次Minor Gc,将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代(Old Generation)
特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。
Major GC
作用范围:主要针对老年代进行回收,但不一定只回收老年代。
触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发Major Gc。
特点:相比Minor GC,Major GC发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。
Full GC
作用范围:对整个堆内存(包括年轻代、老年代以及永久代/元空间)进行回收。
触发条件:
- 直接调用 system.gc()或 Runtime.getRuntime().gc()方法时,虽然不能保证立即执行,但会尝试执行Full GC
- Minor Gc (新生代垃圾回收)时,如果存活的对象无法全部放入老年代,或者老年代空间不足以容纳存活的对象,则会触发Full GC,对整个堆内存进行回收。
- 当永久代(Java 8之前的版本)或元空间(Java 8及以后的版本)空间不足时。
特点:Full GC是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少Full GC的触发。
说说对象分配规则
类加载:当]ava程序运行时,类加载器会加载并解析类文件,将类的字节码数据加载到内存中。类加载过程包括加载、链接(验证、准备、解析)和初始化阶段。
分配内存:在堆内存中为对象分配内存空间。对象的大小由类的实例变量和对象头决定。Java虚拟机通过分配器来分配。内存空间,并确保对象的实例变量被正确初始化。
初始化对象:在分配内存后,Java虚拟机会调用对象的构造函数来初始化对象的实例变量,构造函数负责初始化对象的状态,可以进行一些初始化操作,如设置初始值、执行逻辑等。
设置对象头:在分配内存后,Java虚拟机会为对象设置对象头。对象头包含了对象的元数据信息,如对象的哈希码、锁状态、GC标记等信息。
引用对象:将对象的引用返回给调用者。在Java中,可以通过赋值语句将对象的引用赋给变量,以便后续对对象的操作和访问。
什么是指针碰撞
指针碰撞(Pointer Bumping)是一种内存分配和垃圾回收的技术,在内存管理中常见于使用标记-清除(Mark-Sweep)算法的垃圾收集器中。
指针碰撞的基本思想是将堆内存划分为两个连续的区域,一部分用于存放已分配的对象,另一部分用于空闲内存。当需要分配一个新的对象时,垃圾收集器会将分配指针(Allocation Pointer)设置为空闲内存区域的起始位置,然后将分配指针向前移动,直到找到足够大的空闲内存来分配对象。分配完成后,分配指针会继续向前移动,指向下一个空闲内存块的起始位置。
指针碰撞的优点包括简单高效,适用于内存连续、没有碎片的情况。但它也有一些局限性,例如需要堆内存是连续的,不能有碎片;在多线程环境下需要保证分配指针的线程安全;无法处理内存碎片化等问题。
需要注意的是,指针碰撞通常是针对堆内存的分配,而不是针对栈内存的分配。在栈内存的分配中,通常使用栈指针(Stack Pointer)来动态调整栈帧的位置,而不需要像指针碰撞那样进行手动移动分配指针。
对象的大小如何计算
在Java中,对象的大小通常由以下几个因素决定:
对象头(Object Header):
对象头是存储对象元数据的部分,包括对象的哈希码、锁状态、GC标记等信息。对象头的大小在不同的JVM实现中可能会有所不同,通常在32位JVM中为8字节,64位JVM中为12字节。
实例变量(Instance Variables):
实例变量是对象中声明的所有非静态变量,其大小取决于变量的类型和数量。基本数据类型的大小在Java中是固定的,例如 int 类型占用4字节,double 类型占用8字节。引用类型的大小通常为4字节或8字节,取决于JVM的具体实现。
对齐填充(Padding):
为了提高内存访问的效率,对象的大小通常会进行对齐填充。对齐填充会在对象的末尾添加一些额外的字节,使得对象的大小是某个特定大小的倍数(通常是8字节或者16字节)。对齐填充的大小取决于对象的实例变量和对象头的大小。
因此,对象的大小可以通过以下公式来计算:
对象大小 = 对象头大小 + 实例变量大小 + 对齐填充大小
需要注意的是,对象的大小可能因为JVM的不同实现而有所差异,可以通过JVM提供的工具(如 jmap、jcmd、VisualVM 等)来查看对象的内存布局和大小信息。
对象一定分配在堆中吗
在 Java 中,大多数对象通常都是分配在堆内存中。堆内存是 Java 虚拟机管理的主要内存区域,用于存储对象的实例变量和引用。
然而,并非所有的对象都必须分配在堆中。在某些情况下,Java 虚拟机可以通过逃逸分析(Escape Analysis)等技术来确定对象的生命周期,如果确定对象不会逃逸到方法外部或线程之外,就可以将对象分配在栈上或者进行标量替换。
栈上分配:
一些简单的对象,尤其是在方法中创建的局部对象,可以被分配在栈上。栈上分配的对象生命周期与方法调用的生命周期相同,当方法执行完毕时,对象就会被销毁,无需进行垃圾回收。
标量替换:
对象的实例变量也可以被分解成标量(Scalar),这些标量可以被分配在栈上或者寄存器中。通过标量替换技术,一些对象的实例变量可以不被分配在堆上,而是直接被分配在栈上或者寄存器中,以提高访问效率。
需要注意的是,栈上分配和标量替换等优化技术通常只适用于某些特定的场景和情况,而且取决于具体的 JVM 实现和运行时条件。在大多数情况下,对象仍然会被分配在堆内存中。
可达性分析算法中的STW(Stop-The-World)
STW(Stop-The-World)是垃圾回收(Garbage Collection, GC)中的一个重要概念,特别是在可达性分析算法执行期间。STW是垃圾回收中不可避免的现象,但通过算法优化可以将其影响降到最低。
什么是STW
STW指的是在垃圾回收过程中,JVM(Java虚拟机)需要暂停所有应用程序线程的执行,以便进行安全、准确的垃圾回收操作。在STW期间:
- 所有应用线程被挂起
- 只有垃圾回收线程可以运行
- 系统对外表现为**"卡顿"或"暂停"**
为什么可达性分析需要STW
可达性分析算法(如标记-清除、标记-整理等)需要STW的主要原因包括:
一致性快照:确保在分析对象引用关系时,整个堆内存的状态是"冻结"的,避免分析过程中引用关系被应用程序修改。
防止误标/漏标:
- 误标:将死亡对象错误标记为存活(相对可以接受)
- 漏标:将存活对象错误标记为死亡(严重问题,会导致程序错误)
根节点枚举:在枚举GC Roots时(如全局变量、栈上引用等),必须保证这些引用不被修改。
STW的影响
- 暂停时间:STW时间越长,应用程序停顿越明显
- 吞吐量:频繁或长时间的STW会降低系统整体吞吐量
- 延迟敏感应用:对实时性要求高的应用(如金融交易、游戏)影响更大
如何判断对象可以被回收
引用计数法:给对象添加引用计数器,有引用就+1,引用失效就-1。任何时刻,引用为0,即判断对象死亡
优点:实现简单,效率高
缺点:在主流的Java虚拟机中不被使用,无法解决对象相互循环引用的问题
循环引用:a引用b,b又引用a,但是ab没有被其他引用,应该被回收(对象)
可达性分析算法:从根引用(GCRoots)进行“引用链”遍历扫描,如果可达则对象存活,如果不可达则对象已成为垃圾
缺点:STW时间长(解决三色标记法);内存消耗(需要存储大量的对象数量和引用关系)
如果要使用可达性分析来判断是否可以回收,需要在一个一致性快照中进行STW
三色标记法:属于可达性分析的一种,可以大大的降低STW的时长
强引用、软引用、弱引用、虚引用的区别
Java 根据其生命周期的长短将引用类型又分为强引用、软引用、弱引用、虚引用。
强引用 只要引用关系还在,对象就永远不会被回收
强引用是平常中使用最多的引用,强引用在程序内存不足(OOM)的时候也不会被回收使用
只有在没有其他引用关系或者超过引用作用域,再或者是将对象引用强制赋值为null的情况下,垃圾收集器才会去回收这个对象。
Java String str = new String("str");
软引用 非必须存活的对象,JVM会在内存溢出前对其进行回收
当系统内存充足的时候,不会被回收;当系统内存不足时,它会被回收
软引用通常用在对内存敏感的程序中,比如高速缓存就用到软引用,内存够用时就保留,不够时就回收(早先创建)
弱引用 非必须存活的对象,不管内存是否够用,下次GC一定回收
对于只有弱引用的对象来说,只要有垃圾回收,不管 JVM 的内存空间够不够用,都会回收该对象占用的内存空间
ThreadLocal中的 key 就用到了弱引用,适合用在内存敏感的场景
虚引用 等同于没有引用,对象被回收时会收到通知
虚引用不会决定对象的生命周期,它提供一种确保对象被finalize以后,去做某些事情的机制。
唯一作用就是配合引用队列来监控引用的对象是否被加入到引用队列中,也就是可以准确的让我们知晓对象何时被回收。
说说你对垃圾收集器的理解
垃圾收集器(Garbage Collector)是Java虚拟机的一部分,负责自动管理堆内存中不再使用的对象,释放其占用的内存空间。以下是对垃圾收集器的一些理解:
- 作用:主要作用是在运行时自动回收不再使用的内存,避免内存泄漏和内存溢出问题,提高内存利用率和系统性能。
- 工作原理:垃圾收集器通过扫描堆内存中的对象,标记出所有活跃对象,然后清理掉所有未标记的对象。清理过程中,它会移动对象的位置、压缩内存等操作,以便释放连续的内存空间。
- 分代收集:大部分垃圾收集器采用分代收集算法,将堆内存划分为不同的代(Young Generation、Old Generation等),根据对象的生命周期不同,使用不同的收集策略来进行垃圾回收。
- 垃圾回收算法:常见的垃圾回收算法包括标记-清除算法、复制算法、标记-整理算法、分代收集算法等。不同的算法有不同的优缺点,适用于不同的场景。
- 停顿时间:垃圾收集器的性能指标之一是停顿时间,即在进行垃圾回收时,应用程序的停顿时间。低停顿时间可以提高应用程序的响应速度和性能。
- 调优和选择:Java虚拟机提供了多种垃圾收集器和参数配置选项,可以根据应用程序的特点和性能要求来选择合适的垃圾收集器和配置参数。
总的来说,垃圾收集器是Java虚拟机的重要组成部分,负责自动管理内存,确保应用程序的稳定性和性能。深入理解垃圾收集器的工作原理和性能特征,对于优化Java应用程序的性能和稳定性至关重要。
划分新生代、老年代和元空间的主要目的是:
- 优化垃圾回收效率:通过针对不同生命周期的对象采用不同的垃圾回收策略,提高垃圾回收的效率。
- 减少垃圾回收停顿时间:通过将新生代和老年代分离,可以更精细地控制垃圾回收的时机和范围,减少垃圾回收造成的应用程序停顿时间。
- 提高内存使用效率:通过动态管理元空间的内存,避免了永久代容易出现的内存溢出问题,提高了内存的使用效率和稳定性。
说下JVM中一次完整的 GC 流程
一次完整的垃圾回收(GC)流程包括了对新生代和老年代的垃圾回收。以下是一次完整的GC流程:
初始标记(Initial Mark):
垃圾回收器首先对老年代进行初始标记。在这个阶段,垃圾回收器会标记所有从根节点直接可达的对象,这些根节点包括应用程序的静态变量、线程栈中的引用等。这个阶段的标记是一次短暂的停顿,会导致一小段的应用程序停顿。
并发标记(Concurrent Mark):
在初始标记完成后,垃圾回收器会启动并发标记阶段。在这个阶段,垃圾回收器会通过多线程并发地遍历老年代的对象图,并标记出所有可达的对象。与初始标记不同的是,并发标记过程是在应用程序运行的同时进行的,不会导致应用程序的停顿。
重新标记(Remark):
并发标记阶段完成后,垃圾回收器会进行重新标记。在重新标记阶段,垃圾回收器会停止应用程序的运行,对老年代进行一次短暂的标记操作,以处理在并发标记过程中有可能发生的引用变化。这个阶段的停顿时间通常比初始标记阶段稍长,但仍然比较短暂。
并发清除(Concurrent Sweep):
在重新标记完成后,垃圾回收器会启动并发清除阶段。在这个阶段,垃圾回收器会并发地清理出所有未标记的对象,将它们所占用的内存空间释放出来。与并发标记类似,并发清除过程也是在应用程序运行的同时进行的,不会导致应用程序的停顿。
并发重置(Concurrent Reset):
最后,在并发清除完成后,垃圾回收器会进行并发重置阶段。在这个阶段,垃圾回收器会重置一些内部的数据结构和标记状态,为下一次垃圾回收做准备。
整个GC过程中,初始标记和重新标记阶段会导致短暂的停顿时间,但并发标记、并发清除和并发重置阶段是在应用程序运行的同时进行的,不会导致应用程序的停顿。通过这些阶段的协作,垃圾回收器能够有效地清理出不再使用的内存空间,保证了应用程序的内存稳定性和性能表现。
JVM为什么使用元空间替换了永久代
- 避免内存溢出问题: 永久代的大小是有限的,并且在一些场景下容易发生内存溢出,特别是在大量动态生成类的情况下,如使用动态代理、反射等技术。元空间采用了基于本地内存的存储方式,可以动态地调整大小,避免了永久代固定大小的限制,有效地解决了永久代内存溢出的问题。
- 更好的内存管理: 元空间采用了与Java堆内存分离的方式进行内存分配,与堆内存相比,元空间的内存管理更加灵活、高效。元空间的内存空间由操作系统进行管理,不受虚拟机的内存区域限制,可以根据应用程序的需求动态分配和释放内存。
- 性能优化: 元空间的内存分配和回收采用了更加高效的算法和数据结构,与永久代相比,可以减少垃圾回收的频率和成本,提高了虚拟机的性能和响应速度。
- 与Java开发生态的整合: 随着Java的发展,Java开发生态也在不断地发展和变化,一些新的技术和框架的出现对永久代提出了更高的要求,例如,动态生成类的使用越来越广泛,对永久代的内存管理提出了更高的挑战。元空间的出现可以更好地满足这些新技术和框架的需求,使得Java虚拟机能够更好地适应和整合Java开发生态的变化。
什么是安全点
安全点(Safepoint)是Java虚拟机(JVM)中的一种特殊状态,用于在程序执行时进行线程安全操作和垃圾收集。在安全点处,所有的线程都会被停顿,以便进行特定的操作,例如执行垃圾收集、线程栈的扫描、锁的撤销等。在安全点处,Java虚拟机保证了线程的安全性,可以进行需要全局一致性的操作。
安全点的作用主要包括:
- 垃圾收集:在安全点处进行垃圾收集,可以确保所有的线程都处于停顿状态,避免了在并发垃圾收集过程中的一致性问题。
- 线程栈的扫描:在安全点处,可以扫描线程栈上的对象引用,以进行垃圾回收和对象生命周期的跟踪。
- 锁的撤销:在安全点处,可以对因为线程持有锁而等待的其他线程进行撤销操作,以减少死锁的发生。
在Java虚拟机中,安全点通常由以下几种情况触发:
- 方法调用:在方法调用时进行安全点检查,确保所有的线程都处于安全点状态。
- 循环跳转:在循环跳转时进行安全点检查,确保所有的线程都处于安全点状态。
- 异常抛出:在异常抛出时进行安全点检查,确保所有的线程都处于安全点状态。
- 代码缓存失效:在代码缓存失效时进行安全点检查,确保所有的线程都处于安全点状态。
总的来说,安全点是Java虚拟机中的一种重要机制,用于确保线程的安全性和全局一致性。通过在安全点处进行停顿,Java虚拟机可以执行一些需要全局一致性的操作,例如垃圾收集和锁的撤销,以确保程序的正确性和稳定性。
设置堆内存Xmx应该考虑哪些因素
设置Java虚拟机堆内存大小(Xmx)时,应该考虑以下几个因素:
应用程序的内存需求:
首先,需要考虑应用程序的内存需求。根据应用程序的性质和特点,确定其对内存的需求量。如果应用程序需要处理大量数据或者执行复杂的计算任务,可能需要分配较大的堆内存空间。
系统资源的限制:
确保设置的堆内存大小不超过系统可用内存的限制。考虑到操作系统和其他应用程序的内存占用,需要留出足够的内存空间给其他进程使用,避免因为堆内存设置过大导致系统资源不足的问题。
垃圾回收的影响:
堆内存大小会影响垃圾回收的性能和效率。堆内存过大可能导致垃圾回收器需要更长的时间来执行垃圾回收操作,造成应用程序的停顿时间增加。因此,需要根据应用程序的负载情况和性能要求来平衡堆内存大小和垃圾回收的影响。
长期稳定性:
考虑到应用程序的长期稳定性和性能表现,建议适当留有余地,不要将堆内存设置得过小,避免在后续运行过程中出现内存溢出或频繁的垃圾回收现象。
性能测试和调优:
最后,根据实际情况进行性能测试和调优。通过观察应用程序的内存使用情况、垃圾回收情况和性能表现,逐步调整堆内存大小,找到适合应用程序的最佳设置。
综上所述,设置Java虚拟机堆内存大小时需要综合考虑应用程序的内存需求、系统资源限制、垃圾回收影响、长期稳定性和性能测试等因素,以找到适合应用程序的最佳配置。
CPU百分百问题如何排查
当遇到 CPU 使用率持续高达 100% 的问题时,可以通过以下步骤进行排查:
检查系统进程:
首先,检查系统进程,查看是否有某个进程占用了大量的 CPU 资源。可以使用系统自带的任务管理器(如Windows的任务管理器、Linux的top命令、macOS的Activity Monitor)来查看进程的 CPU 使用情况。
检查应用程序进程:
如果是应用程序占用了大量的 CPU 资源,可以通过监控工具(如VisualVM、JConsole、jstack)来查看Java进程的线程堆栈,找出哪些线程在消耗 CPU。可能是某个线程执行了耗时操作或者出现了死循环等情况。
查看日志:
检查应用程序的日志文件,查看是否有异常或错误信息。有时候异常抛出后没有被捕获,导致线程进入死循环,消耗了大量的 CPU 资源。
使用性能分析工具:
使用性能分析工具来诊断 CPU 使用率过高的原因。常用的性能分析工具包括:YourKit Java Profiler、VisualVM、JProfiler 等。这些工具可以帮助定位代码中的性能瓶颈和优化点。
代码审查:
对可能引起 CPU 使用率过高的代码进行审查和分析。查看代码中是否存在大量的循环、IO 操作、数据库查询等耗时操作,尝试优化或者异步处理这些操作。
监控系统资源:
监控系统的其他资源使用情况,如内存、磁盘等,看是否存在其他方面的问题导致 CPU 使用率过高。