一个Java进程占用的内存都哪些部分?

典型回答 Java进程,在运行时会占用多块内存区域,其中我们比较熟的就是堆、栈、等区域,但是,其实详细列举的话还是有挺多的,主要包含以下部分区域: 堆 堆是存储对象实例的运行时内存区域。它是虚拟机运行时的内存总体的最大的一块,也一直占据着虚拟机内存总量的一大部分。Java堆由Java虚拟机管理,用于存放对象实例,是几乎所有的对象实例都要在上面分配内存。此外,Java堆还用于垃圾回收,虚拟机发现没有被引用的对象时,就会对堆中对象进行垃圾回收,以释放内存空间。 堆上根据分代可以分为年轻代和老年代。 栈 Java虚拟机栈:一种线程私有的存储器,用于存储Java中的局部变量。根据Java虚拟机规范,每次方法调用都会创建一个栈帧,该栈帧用于存储局部变量,操作数栈,动态链接,方法出口等信息。当方法执行完毕之后,这个栈帧就会被弹出,变量作用域就会结束,数据就会从栈中消失。 本地方法栈:本地方法栈是一种特殊的栈,它与Java虚拟机栈有着相同的功能,但是它支持本地代码( Native Code )的执行。本地方法栈中存放本地方法( Native Method )的参数和局部变量,以及其他一些附加信息。这些本地方法一般是用C等本地语言实现的,虚拟机在执行这些方法时就会通过本地方法栈来调用这些本地方法。 堆外内存 堆外内存则是在堆之外的一块持久化的内存空间。这种内存通常由操作系统管理,因此对于大规模数据存储和快速访问来说,使用堆外内存可以提供更好的性能和控制。在我们熟知的C语言中,分配的就是机器内存,就和我们说的堆外内存类似了。 ✅什么是堆外内存?如何使用堆外内存? 堆外内存包括了元空间、压缩类空间、**代码缓冲区、直接缓冲区 **4个部分。 元空间(Meta Space):从JDK 1.8开始,HotSpot虚拟机对方法区的实现进行了重大改变。永久代被移除,取而代之的是元空间(Metaspace)。元空间是用来来存储类的元数据信息的。 压缩类空间(Compressed Class Space):压缩类空间是元空间的一部分,专门用于存储类的元数据,而且在使用64位JVM时,通过使用较小的指针(通常是32位的指针)来引用类的元数据,从而减少了内存的使用量。 代码缓冲区(Code Cache):主要用于存储编译器编译后的本地机器代码。当Java方法被JVM的即时编译器(JIT编译器)编译成本地代码(Native Code)后,这些代码被存储在代码缓冲区中,以便后续直接执行,提高程序运行效率。 **直接缓冲区(****Direct Buffer):**直接缓冲区(Direct Buffer)是Java NIO中的一个概念,用于在Java程序和操作系统之间高效地传递数据。与传统的Java IO相比,NIO引入了通道(Channel)和缓冲区(Buffer)的概念,使得数据的读写更加高效。直接缓冲区就是这些缓冲区中的一种,其特点是它在物理内存中分配存储空间,从而减少了数据在Java堆内存和操作系统之间来回复制的需要,提高了数据处理的效率。 非JVM内存 本地运行库指的是操作系统中用本地编程语言(如C或C++)编写的库,这些库直接运行在操作系统上,而不是在Java虚拟机(JVM)内部执行。 这些库提供了一种方式,允许Java程序执行那些Java本身不直接支持的操作,比如系统级调用、访问特定硬件设备或使用特定于平台的特性和函数。由于这些库是用非Java语言编写的,它们能够提供更接近硬件层面的性能和功能。 **JNI(Java Native Interface)**是一个编程框架,允许Java代码与本地代码(如C和C++代码)进行交互。它是Java平台的一部分,为Java程序调用本地方法提供了一套标准的接口。通过JNI,Java程序能够使用本地方法来执行那些用Java语言难以或无法直接实现的任务,比如直接访问系统资源、调用操作系统API、使用特定硬件设备或实现性能关键型组件。 扩展知识 JVM运行时内存区域 ✅JVM的运行时内存区域是怎样的?

March 22, 2026 · 1 min · santu

一个对象的结构是什么样的?

Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。 HotSpot JVM设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。 每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头、实例数据以及对齐填充。 对象头(Object Header): 对象头是每个Java对象的固定部分,它包含了用于管理对象的元数据信息。对象头的结构在HotSpot中是根据对象的类型(即是否是数组对象、是否启用偏向锁等)而变化的,但一般情况下,对象头包含以下信息: Mark Word(标记字):用于存储对象的标记信息,包括对象的锁状态、GC标记等。 Class Metadata Address(类元数据地址):指向对象所属类的元数据信息,包括类的类型、方法、字段等。 实例数据(Instance Data): 实例数据是对象的成员变量(字段)的实际存储区域,它包含了对象的各个字段的值。实例数据的大小取决于对象所包含的字段数量和字段类型。 对齐填充(Padding): 对齐填充是为了使得对象的起始地址符合特定的对齐要求,以提高访问效率。由于虚拟机要求对象的起始地址必须是8字节的倍数(在某些平台上要求更大),因此可能需要在对象的实例数据末尾添加额外的字节来对齐。 下面以一段代码段为例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Clazz { public static int a = 1; public int b; public Clazz(int b) { this.b = b; } } public static void main(String[] args) { int c = 10; Clazz instance1 = new Clazz(2); Clazz instance2 = new Clazz(3); } 当上面的代码段执行完后,会在JVM呈现出如下模式: ...

March 22, 2026 · 3 min · santu

什么是AOT编译?和JIT有啥区别?

典型回答 我们都知道,Java中有两种编译方法: 1、javac把java代码编译成字节码,然后由Java虚拟机解释执行。 2、JIT把java的字节码直接编译成机器码,然后由Java虚拟机直接运行。 ✅简单介绍一下JIT优化技术? 但是,JIT编译有一些比较明显的缺点也是不能忽视的: 增加启动时间:由于JIT编译器在程序运行时编译代码,它可能导致应用程序的启动时间较长。 可能会影响应用性能:JIT编译是需要进行热点代码检测、代码编译等动作的,这些都是要占用运行期的资源,所以,JIT编译过程中也可能会影响应用性能。 而在如今云原生盛行的今天,应用的快速启动以及减少预热时长是非常重要的,特别是Serverless场景中,所以,一个新兴的编译器GraalVM就诞生了。他提出了一种新的编译方式——Ahead of Time,即AOT编译。 ✅为什么云原生对应用的启动速度要求很高? AOT编译,翻译一下就是提前编译,它不像JIT一样在运行期才生成机器码,而是在编译期间就将字节码转换为机器码,这就直接省去了运行时对JVM的依赖。这是一种典型的静态编译技术。 java 静态编译,是指将java程序的字节码在单独的离线阶段编译为汇编代码,它的输入是Java的字节码,输出是native image。 这里多说一句,很多人会误以为编译期的编译不是javac吗?其实javac只是把源代码(.java)编译成中间代码(.class),这种中间代码我们叫做字节码,而字节码并不是机器代码,无法直接执行,需要解释器进行解释执行。详见: ✅Java是编译型还是解释型? 因为AOT编译是在编译期就生成机器代码了,所以,应用启动时就不需要编译,那么他就可以大大的减少应用的启动时间,提升系统的整体性能。所以他非常适用于对启动时间敏感的场景,例如云原生应用。 扩展知识 静态编译的优势 与传统的Java的运行时编译(动态编译/jit)相比,静态编译主要有几个重要的优势: 1、机器执行的时候执行的是经过编译优化的本地代码。执行本地代码可以非常的高效和快速,并不需要进行解释执行和JIT编译,就可以直接执行。 2、静态编译后的可执行程序自包含了轻量级运行时支持,所以他在运行时不再需要依赖额外的JVM。 3、解决应用程序的冷启动问题。有了静态编译后的本地代码,应用程序就可以快速地启动,不再解释执行,也不再需要JIT的预热。 4、打破Java程序与本地代码之间的边界。因为编译后的代码也是本地代码,所以JNI调用的开销更低了。 静态编译的局限性 封闭性假设 一个Java程序之所以能做静态编译,需要满足一个至关重要的前提——封闭性假设。 什么是封闭性假设,也就是说他要求所有运行时的内容必须在编译时可见,并且可以被编译到native image中。但是Java中有很多代码是没有办法在编译期就确定的,比如反射,他就是不满足封闭性假设的。 其他的类似的违反封闭性假设的特性还有动态代理、序列化、JNI、动态类加载等等。所以,遇到这些代码的时候,就需要额外的适配来解决。带来了很多的复杂性,也给静态编译带来了一定的局限性。 平台相关性 Java有一个很重要的特性就是平台无关性,但是随着静态编译的盛行,这个说法已经并不一定就成立了。 静态编译以后的代码程序,其实就是平台相关的了。 ✅Java一定就是平台无关的吗?

March 22, 2026 · 1 min · santu

什么是Class常量池,和运行时常量池关系是什么?

典型回答 Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 Class是用来保存常量的一个媒介场所,并且是一个中间场所。**Class文件中的常量池部分的内容,会在运行期加载到常量池中去。 ** 扩展知识 查看Class常量池 由于不同的Class文件中包含的常量的个数是不固定的,所以在Class文件的常量池入口处会设置两个字节的常量池容量计数器,记录了常量池中常量的个数。 当然,还有一种比较简单的查看Class文件中常量池的方法,那就是通过javap命令。对于以上的HelloWorld.class,可以通过 1 javap -v HelloWorld.class 查看常量池内容如下: 从上图中可以看到,反编译后的class文件常量池中共有16个常量。而Class文件中常量计数器的数值是0011,将该16进制数字转换成10进制的结果是17。 原因是与Java的语言习惯不同,常量池计数器是从1开始而不是从0开始的,常量池的个数是10进制的17,这就代表了其中有16个常量,索引值范围为1-16。

March 22, 2026 · 1 min · santu

什么是三色标记算法?

典型回答 三色标记算法是一种JVM中垃圾标记的算法,他可以减少JVM在GC过程中的STW时长,他是CMS、G1等垃圾收集器中主要使用的标记算法。 在出现三色标记算法之前,JVM中垃圾对象的标记主要采用可达性分析算法及引用计数法。但是这两种算法存在以下问题: 1、循环引用问题,如果两个对象互相引用,就形成了一个环形结构,如果采用引用计数法的话,那么这两个对象将永远无法被回收。 2、STW时间长,可达性分析的整个过程都需要STW,以避免对象的状态发生改变,这就导致GC停顿时长很长,大大影响应用的整体性能。 为了解决上面这些问题,就引入了三色标记法。 三色标记法将对象分为三种状态:白色、灰色和黑色。 白色:该对象没有被标记过。在垃圾回收周期开始时,所有对象都是白色。这代表着它们是潜在的垃圾 灰色:该对象已经被标记过了,但该对象的引用的对象还没标记完。 黑色:该对象已经被标记过了,并且他的全部引用对象也都标记完了。 三色标记法的标记过程可以分为三个阶段:初始标记(Initial Marking)、并发标记(Concurrent Marking)和重新标记(Remark)。 初始标记: 所有对象初始都是白色。遍历所有的根对象,将根对象和直接引用的对象标记为灰色。在这个阶段中,垃圾回收器只会扫描被直接或者间接引用的对象,而不会扫描整个堆。因此,初始标记阶段的时间比较短。(Stop The World) 并发标记:在这个过程中,垃圾回收器会从灰色对象开始遍历整个对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用写屏障(Write Barrier)技术来保证并发标记的正确性。(不需要STW) 重新标记:重新标记的主要作用是标记在并发标记阶段中被修改的对象以及未被遍历到的对象。这个过程中,垃圾回收器会从灰色对象重新开始遍历对象图,将被引用的对象标记为灰色,并将已经遍历过的对象标记为黑色。(Stop The World) 在重新标记阶段结束之后,垃圾回收器会执行清除操作,将未被标记为可达对象的对象进行回收,从而释放内存空间。 以上三个标记阶段中,初始标记和重新标记是需要STW的,而并发标记是不需要STW的。其中最耗时的其实就是并发标记的这个阶段,因为这个阶段需要遍历整个对象树,而三色标记把这个阶段做到了和应用线程并发执行,大大降低了GC的停顿时长。 扩展知识 并发标记的写屏障 并发标记过程中,应用程序线程可能会修改对象图,因此垃圾回收器需要使用写屏障(Write Barrier)技术来保证并发标记的正确性。 写屏障是一种在对象引用被修改时,将其新的引用信息记录在特殊数据结构中的机制。在三色标记法中,写屏障技术被用于记录对象的标记状态,并且只对未被标记过的对象进行标记。 当应用程序线程修改了一个对象的引用时,写屏障会记录该对象的新标记状态。如果该对象未被标记过,那么它会被标记为灰色,以便在垃圾回收器的下一次遍历中进行标记。如果该对象已经被标记为可达对象,那么写屏障不会对该对象进行任何操作。 通过使用写屏障技术,可以使得三色标记法过程中标记更加准确。然而,尽管写屏障对于维护垃圾收集器的准确性至关重要,它们仍然存在一些局限性。 性能开销: 写屏障会引入额外的性能开销,因为每次对象引用更新时都需要执行额外的代码。这种开销可能导致系统性能下降,尤其是在高度并发的场景中。 并发修改的挑战: 在高度并发的应用中,对象的引用可能会频繁变化。写屏障需要在每次引用变化时及时更新信息,但在极端并发条件下,可能难以捕捉到所有的变化。 保守策略导致的多标: 为了避免误删除有效对象,一些垃圾收集器可能采取保守策略,在存在不确定性时选择保留对象。这可能导致实际上已经不再使用的对象被错误地标记为存活。 优化策略的双刃剑: 为了减轻性能开销,某些垃圾收集器可能采用优化策略,例如只在特定条件下激活写屏障。这种优化有可能导致某些引用更新被错过,影响标记的准确性。 所以,三色标记法即使在并发标记过程中用了写屏障,还是可能会带来多标和少标的问题。 多标的问题 所谓多标,其实就是这个对象原本应该被回收掉的白色对象,但是被错误的标记成了黑色的存活对象。从而导致这个对象没有被GC回收掉。 这个一般发生在并发标记过程中,该对象还是有引用的,但是在过程中,应用程序执行过程中把他的引用关系删除了,导致他变成了一个垃圾对象。 多标的话,会产生浮动垃圾,这个问题一般都不太需要解决,因为这种垃圾一般都不会太多,另外在下一次GC的时候也都能被回收掉。 怎么解决漏标的问题 所谓漏标,和多标刚好相反,就是说一个对象本来应该是黑色存活对象,但是没有被正确的标记上,导致被错误的垃圾回收掉了。 这种情况一旦发生是很危险的,一个正常使用的对象被垃圾回收掉了,这对系统来说是灾难性的问题,那么如何解决呢? 具体的解决方式,在CMS和G1中也不太一样。CMS采用的是增量更新方案,G1则采用的是原始快照的方案。 漏标的问题想要发生,需要同时满足两个充要条件: 1、至少有一个黑色对象在自己被标记之后指向了这个白色对象 2、所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用 那么,增量更新方案就是破坏了第一个条件,而原始快照方案就是破坏了第二个条件。 增量更新 “至少有一个黑色对象在自己被标记之后指向了这个白色对象”,这个条件如果被破坏了,那么就不会出现漏标的问题。所以: 如果有黑色对象在自己标记后,又重新指向了白色对象。那么我就把这个黑色对象的引用记录下来,在后续「重新标记」阶段再以这个黑色对象为根,对其引用进行重新扫描。通过这种方式,被黑色对象引用的白色对象就会变成灰色,从而变为存活状态。 举个例子:在清理房间时,你已经检查了某个抽屉(对象)并决定里面的东西都是需要的(标记为黑色)。但是,在你继续清理其他地方时,家人回来并在这个抽屉里放了一些新的东西。增量更新就像是他们给你留了一个便条,提醒你“这个抽屉有变化,请重新检查”,确保你不会错过任何东西。 这种方式有个缺点,就是会重新扫描新增的这部分黑色对象,会浪费多一些时间。但是其实这个浪费还好,因为本来这种漏标的情况就并不是特别常见,所以这部分需要重新扫描的黑色对象也并不多。 增量更新就是实时记录变化,确保每一次变化都会被重新检查,避免漏掉任何可能被错误视为垃圾的活跃对象。 原始快照 “所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用”,这个条件如果被破坏了,那么就不会出现漏标的问题。所以: 如果灰色对象在扫描完成前删除了对白色对象的引用,那么我们就在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。 在后续「重新标记」阶段再以这些白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。 ...

March 22, 2026 · 1 min · santu

什么是双亲委派?如何破坏?

典型回答 下图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。 (JDK 1.8及之前的类加载器) 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,先检查是否已经加载过这个类,如果有则直接返回,然后它也不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。 双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中,代码简单,逻辑清晰易懂:先检查类是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。 双亲委派模型主要是由**ClassLoader#loadClass**实现的,我们只需要自定义类加载器,并且重写其中的loadClass方法,即可破坏双亲委派模型。 扩展知识 JAVA有哪几种默认的类加载器 ✅JDK1.8和1.9中类加载器有哪些不同 为什么需要双亲委派模型 使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。 相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。 loadClass和findClass findClass用于重写类加载逻辑、loadClass方法的逻辑里如果父类加载器加载失败则会调用自己的findClass方法完成加载,保证了双亲委派规则。 1、如果不想打破双亲委派模型,那么只需要重写findClass方法即可 2、如果想打破双亲委派模型,那么就重写整个loadClass方法 有哪些破坏双亲委派的例子 向前兼容 由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。 为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。 JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。 SPI实现 双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。 这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码。 为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。 有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打坡了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。 Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。 TOMCAT 一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的,如果采用默认的类加载机制,那么就会无法加载多个相同的类。 Tomcat 为了实现隔离性,所以并没有完全遵守双亲委派的原则: ✅Tomcat的类加载机制是怎么样的?

March 22, 2026 · 1 min · santu

什么是堆外内存?如何使用堆外内存?

典型回答 在Java中,JVM的运行时区域分为堆、栈、方法区等,当我们提到内存的时候,一般都是指的是堆内存,然而,堆内存是有一些限制的,首先就是堆内存的大小他不是无限的,而且堆内存中的垃圾回收机制会导致堆内存的不稳定性和延迟(产生碎片、STW等),而且对于大量的数据或需要较低的内存访问延迟的应用,堆内存可能不够高效。 堆外内存则是在堆之外的一块持久化的内存空间。这种内存通常由操作系统管理,因此对于大规模数据存储和快速访问来说,使用堆外内存可以提供更好的性能和控制。在我们熟知的C语言中,分配的就是机器内存,就和我们说的堆外内存类似了。 (图中的直接内存就是堆外内存) 但是,需要注意的是,堆外内存不受Java垃圾回收机制的管理。在不再需要堆外内存时,务必手动释放内存资源,否则可能会造成内存泄漏和应用程序异常。因此,堆外内存的使用一般在特定场景和对内存管理有丰富经验的情况下才推荐使用。 尽管堆外内存不受Java堆大小的限制,但它仍然受到系统可用内存的限制。如果操作系统没有足够的可用内存供应用程序使用,就有可能导致堆外内存分配失败,从而抛出OutOfMemoryError。 在Java中,堆外内存就可以理解为在JVM之外的机器内存,想要使用堆外内存,有两种方式,分别是借助Unsafe类以及NIO。 Unsafe这个没啥好说的了,他主要就是用来和操作系统底层交互的,关于用Unsafe来操作堆外内存的示例,在下面的文档中有,这里就不重复说了: ✅什么是Unsafe? NIO中引入了ByteBuffer类,也可以用于处理堆外内存: 使用ByteBuffer类的allocateDirect()方法来创建一个DirectByteBuffer实例,它表示堆外内存的缓冲区。 1 2 int capacity = 1024; // 指定内存大小 ByteBuffer buffer = ByteBuffer.allocateDirect(capacity); 使用ByteBuffer的put()方法写入数据到堆外内存,使用get()方法从堆外内存读取数据。 1 2 3 4 5 6 7 8 9 String dataToWrite = "Hello, this is hollis testing off-heap memory!"; buffer.put(dataToWrite.getBytes()); buffer.flip(); // 切换到读模式 byte[] dataToRead = new byte[buffer.remaining()]; buffer.get(dataToRead); System.out.println(new String(dataToRead)); 由于堆外内存不受Java垃圾回收机制管理,需要手动释放内存资源,避免内存泄漏。通过调用ByteBuffer的cleaner()方法获取Cleaner对象,并调用其clean()方法来释放堆外内存。 1 2 sun.misc.Cleaner cleaner = ((sun.nio.ch.DirectBuffer) buffer).cleaner(); cleaner.clean(); 虽然DirectByteBuffer分配的堆外内存不受JVM堆内存的GC直接管理,但HotSpot JVM确实提供了一种机制来间接管理这部分内存的回收。 ...

March 22, 2026 · 1 min · santu

什么是方法区?是如何实现的?

典型回答 方法区是Java虚拟机规范定义的一块用于存储类信息、常量、静态变量、编译器编译后的代码等数据的内存区域。 方法区是线程共享的,每个虚拟机实例只有一个方法区。**实现方法区的方式在不同的JDK厂商以及不同版本中可能有所不同的。**所谓规范是规范,实现是实现,两码事儿。 我们拿主流的HotSpot虚拟机来说明一下他的实现机制。 在JDK 1.7及之前的版本中,方法区通常被实现为永久代(Permanent Generation),用于存储类信息、常量池、静态变量、即时编译器编译后的代码等数据。 不过在1.6中,方法区中包含了字符串常量池,而在1.7中,把字符串常量池、和静态变量都移到了堆内存中。这么做的主要原因是因为永久代的 GC 回收效率太低,只有在FullGC的时候才会被执行回收。但是Java中往往会有很多字符串也是朝生夕死的,将字符串常量池放到堆中,能够更高效及时地回收字符串内存 下图是1.6VS1.7的对比: 由于永久代有固定的大小,且不容易调整,因此在一些场景下容易导致内存溢出。例如,如果应用程序中使用大量的动态生成类或者频繁地加载卸载类,就可能导致永久代溢出。 所以,从JDK 1.8开始,HotSpot虚拟机对方法区的实现进行了重大改变。永久代被移除,取而代之的是元空间(Metaspace)。元空间是使用本地内存(Native Memory)来存储类的元数据信息的,它不再位于堆内存中。 元空间的特点是可以根据应用程序的需要动态调整其大小,因此更加灵活。它能够有效地避免了永久代的内存溢出问题,并且可以减少垃圾回收的压力。元空间的内存使用量受限于操作系统对本地内存的限制。

March 22, 2026 · 1 min · santu

什么是编译和反编译?

典型回答 编程语言(Programming Language)分为低级语言(Low-level Language)和高级语言(High-level Language)。 机器语言(Machine Language)和汇编语言(Assembly Language)属于低级语言,直接用计算机指令编写程序。 而C、C++、Java、Python等属于高级语言。低级语言是计算机认识的语言、高级语言是程序员认识的语言。 将便于人编写、阅读、维护的高级计算机语言所写作的源代码程序,翻译为计算机能解读、运行的低阶机器语言的程序的过程就是编译。负责这一过程的处理的工具叫做编译器。 我们可以通过javac命令将Java程序的源代码编译成Java字节码,即我们常说的class文件。这是我们通常意义上理解的编译。但是,字节码并不是机器语言,要想让机器能够执行,还需要把字节码翻译成机器指令。这个过程是Java虚拟机做的,这个过程也叫编译。是更深层次的编译。 在编译原理中,把源代码翻译成机器指令,一般要经过以下几个重要步骤: 我们可以把将.java文件编译成.class的编译过程称之为前端编译。把将.class文件翻译成机器指令的编译过程称之为后端编译。 **反编译的过程与编译刚好相反,就是将已编译好的编程语言还原到未编译的状态,也就是找出程序语言的源代码。**就是将机器看得懂的语言转换成程序员可以看得懂的语言。Java语言中的反编译一般指将class文件转换成java文件。 扩展知识 反编译工具 介绍3个Java的反编译工具:javap、jad和cfr javap ✅javap命令的作用是什么? jad jad是一个比较不错的反编译工具,只要下载一个执行工具,就可以实现对class文件的反编译了。还是上面的源代码,使用jad反编译后内容如下: 命令:jad switchDemoString.class 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class switchDemoString { public switchDemoString() { } public static void main(String args[]) { String str = "world"; String s; switch((s = str).hashCode()) { default: break; case 99162322: if(s.equals("hello")) System.out.println("hello"); break; case 113318802: if(s.equals("world")) System.out.println("world"); break; } } } 看,这个代码你肯定看的懂,因为这不就是标准的java的源代码么。这个就很清楚的可以看到原来字符串的switch是通过equals()和hashCode()方法来实现的。 ...

March 22, 2026 · 2 min · santu

什么是跨代引用,有什么问题?

典型回答 JVM的跨代引用问题是指在Java堆内存的不同代之间存在引用关系,导致对象在不同代之间的引用被称为跨代引用。比如:新生代到老年代的引用,老年代到新生代的引用等。 跨代引用会有什么问题呢? 关于这个知识点,我看了网上很多资料,基本上没有哪个能解释的很清楚的,包括《深入理解Java虚拟机(第三版)》也是只说了其中的一部分,我综合了很多资料,加上自己的理解,试图把这个事讲的清楚一点。 首先看下面这张图: 假如,我们现在JVM的堆是上面这种情况,那么在进行一次MinorGC(YoungGC)的时候,会从GC Root出发,然后进行可达性分析,假如当前正在进行一次Young GC,如果他发现一个对象处于老年代,那么JVM就会中断这条路径。 那么这时候,JVM就会认为只有A和B是可达的,就会在接下来的Young GC中把E回收掉,但是其实E是有引用的,只不过他的引用在老年代,发生了跨代引用。 想要解决解决这个问题,有两种简单的做法: 1、在做YoungGC的时候,GC Root出发后扫描到老年代对象后不中断,继续扫描和标记,把所有在年轻代的对象都标记上。 2、在YoungGC的实时,把老年代的所有对象也作为GC Root,进行可达性分析扫描。 以上两种做法,其实成本都太高了,甚至第一种要比第二种成本还要高,因为他不仅要扫描,还需要不断地做标记。 那么,于是就有一个好的办法出现了,那就是定义了一个全局的数据结构——Remembered Set。 Remembered Set 的主要作用是跟踪老年代对象与年轻代)对象之间的引用关系,以帮助识别老年代中存活对象。他的核心目标是减少全堆扫描的开销。老年代中的对象通常存活更长时间,因此为了回收年轻代,JVM需要知道哪些老年代对象引用了年轻代对象,以确保不会错误地回收正在被老年代引用的年轻代对象。 Remembered Set 记录了老年代对象指向年轻代对象的引用关系,此后当发生Minor GC时,垃圾回收器不需要扫描整个老年代来确定哪些对象存活。它只需扫描Remembered Set中的条目,从而减少了扫描的开销。 所以,在Remember Set中的对象也会被加入到GC Roots进行扫描: 而在Remembered Set的实现中,比较常见的一种叫做Card Table。 以上,就是关于跨代引用的介绍,关于Card Table的具体实现,以及在CMS和G1中解决跨代引用的具体实现,我后面单独讲解。这里先占个坑。

March 22, 2026 · 1 min · santu

留言给博主