Java中类加载的过程是怎么样的?

典型回答 Java中类的加载阶段分为加载(Loading)、链接(Linking)和初始化(Initialization)。其中连接过程又包含了验证、准备和解析。 加载阶段 加载阶段的目的是将类的.class文件加载到JVM中。在这个阶段,JVM会根据类的全限定名来获取定义该类的二进制字节流,并将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。 加载过程会创建一个java.lang.Class类的实例来表示这个类。这个Class对象作为程序中每个类的数据访问入口。 链接阶段 在链接阶段,Java类加载器对类进行验证、准备和解析操作。将类与类的关系(符号引用转为直接引用)确定好,校验字节码 验证:校验类的正确性(文件格式,元数据,字节码,二进制兼容性),保证类的结构符合JVM规范。 准备:为类变量分配内存并设置类变量的默认初始值,这些变量使用的内存都在方法区中分配。(这里初始化的是类变量,即static字段,实例变量会在对象实例化时随对象一起分配在Java堆中。) 解析:把类的符号引用转为直接引用_(类或接口、字段、类方法、接口方法、方法类型、方法句柄和访问控制修饰符7类符号引用 )_ 初始化阶段 初始化是类加载过程的最后一步。在这一步,Java 虚拟机(JVM)才真正开始执行你在类里写的代码,比如静态变量的赋值和静态代码块中的内容。这个阶段,JVM会执行类构造器 **<clinit>()** 方法,即执行类中所有 static {} 静态代码块 和 static 变量的显式赋值。 **这里用到了“懒加载”的思想:只有当你的程序第一次主动使用某个类时(比如创建它的对象、访问它的静态成员等),JVM 才会去初始化这个类。**但是需要注意:类的加载(把类的信息读入内存)可能早就发生了,但初始化一定是在首次主动使用时才触发。而且,静态代码块只会在初始化阶段执行一次。 当遇到 new 、 getstatic、putstatic或invokestatic 这4条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时会进行类的初始化 使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类 当使用 JDK1.7 的动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化 扩展知识 什么是符号引用和直接引用 **符号引用(Symbolic Reference)**是一种用来表示引用目标的符号名称,比如类名、字段名、方法名等。符号引用与实际的内存地址无关,只是一个标识符,用于描述被引用的目标,类似于变量名。符号引用是在编译期间产生的,在编译后的class文件中存储。 **直接引用(Direct Reference)**是实际指向目标的内存地址,比如类的实例、方法的字节码等。直接引用与具体的内存地址相关,是在程序运行期间动态生成的。 假设有两个类A和B,其中A类中有一个成员变量x,B类中有一个方法foo,其中会调用A类中的成员变量x: 1 2 3 4 5 6 7 8 9 10 11 public class A { public int x; } public class B { public void foo() { A a = new A(); a.x = 10; System.out.println("x = " + a.x); } } 在B类中调用A类的成员变量x时,实际上是通过符号引用来引用A类中的x变量。在解析阶段,Java虚拟机会将A类中的符号引用转换为直接引用,定位到具体的x变量实现,并为B类生成一条指令,用于获取该变量的内存地址。 ...

March 22, 2026 · 1 min · santu

Java是如何实现的平台无关?

典型回答 平台无关性就是一种语言在计算机上的运行不受平台的约束,一次编译,到处执行(Write Once ,Run Anywhere)。 平台无关性的实现 对于Java的平台无关性的支持,就像对安全性和网络移动性的支持一样,是分布在整个Java体系结构中的。其中扮演着重要的角色的有Java语言规范、Class文件、Java虚拟机(JVM)等。 在计算机世界中,计算机只认识0和1,所以,真正被计算机执行的其实是由0和1组成的二进制文件。 但是,我们日常开发使用的C、C++、Java、Python等都属于高级语言,而非二进制语言。所以,想要让计算机认识我们写出来的Java代码,那就需要把他”翻译”成由0和1组成的二进制文件。这个过程就叫做编译。负责这一过程的处理的工具叫做编译器。 在Java平台中,想要把Java文件,编译成二进制文件,需要经过两步编译,前端编译和后端编译: 前端编译主要指与源语言有关但与目标机无关的部分。Java中,我们所熟知的javac的编译就是前端编译。除了这种以外,我们使用的很多IDE,如eclipse,idea等,都内置了前端编译器。主要功能就是把.java代码转换成.class代码。 这里提到的.class代码,其实就是Class文件。 后端编译主要是将中间代码再翻译成机器语言。Java中,这一步骤就是Java虚拟机来执行的。 所以,我们说的,Java的平台无关性实现主要作用于以上阶段。如下图所示: 我们从后往前介绍一下这三位主演:Java虚拟机、Class文件、Java语言规范 Java虚拟机 所谓平台无关性,就是说要能够做到可以在多个平台上都能无缝对接。但是,对于不同的平台,硬件和操作系统肯定都是不一样的。 对于不同的硬件和操作系统,最主要的区别就是指令不同。比如同样执行a+b,A操作系统对应的二进制指令可能是10001000,而B操作系统对应的指令可能是11101110。那么,想要做到跨平台,最重要的就是可以根据对应的硬件和操作系统生成对应的二进制指令。 而这一工作,主要由我们的Java虚拟机完成。虽然Java语言是平台无关的,但是JVM却是平台有关的,不同的操作系统上面要安装对应的JVM。 上图是Oracle官网下载JDK的指引,不同的操作系统需要下载对应的Java虚拟机。 有了Java虚拟机,想要执行a+b操作,A操作系统上面的虚拟机就会把指令翻译成10001000,B操作系统上面的虚拟机就会把指令翻译成11101110。 ps:图中的Class文件中内容为mock内容 所以,Java之所以可以做到跨平台,是因为Java虚拟机充当了桥梁。他扮演了运行时Java程序与其下的硬件和操作系统之间的缓冲角色。我们可以理解为,Java的平台无关性,正是因为JVM的平台有关性 字节码 各种不同的平台的虚拟机都使用统一的程序存储格式——字节码(ByteCode)是构成平台无关性的另一个基石。Java虚拟机只与由字节码组成的Class文件进行交互。 我们说Java语言可以Write Once ,Run Anywhere。这里的Write其实指的就是生成Class文件的过程。 因为Java Class文件可以在任何平台创建,也可以被任何平台的Java虚拟机装载并执行,所以才有了Java的平台无关性。 Java语言规范 已经有了统一的Class文件,以及可以在不同平台上将Class文件翻译成对应的二进制文件的Java虚拟机,Java就可以彻底实现跨平台了吗? 其实并不是的,Java语言在跨平台方面也是做了一些努力的,这些努力被定义在Java语言规范中。 比如,Java中基本数据类型的值域和行为都是由其自己定义的。而C/C++中,基本数据类型是由它的占位宽度决定的,占位宽度则是由所在平台决定的。所以,在不同的平台中,对于同一个C++程序的编译结果会出现不同的行为。 举一个简单的例子,对于int类型,在Java中,int占4个字节,这是固定的。 但是在C++中却不是固定的了。在16位计算机上,int类型的长度可能为两字节;在32位计算机上,可能为4字节;当64位计算机流行起来后,int类型的长度可能会达到8字节。(这里说的都是可能哦!) 通过保证基本数据类型在所有平台的一致性,Java语言为平台无关性提供了强有力的支持。 知识扩展 平台无关性好处 作为一门平台无关性语言,无论是在自身发展,还是对开发者的友好度上都是很突出的。 因为其平台无关性,所以Java程序可以运行在各种各样的设备上,尤其是一些嵌入式设备,如打印机、扫描仪、传真机等。随着5G时代的来临,也会有更多的终端接入网络,相信平台无关性的Java也能做出一些贡献。 同时,Java通过Swing,FX,可以对客户端进行编写,开发者可以通过Java编写一次,就可以运行到IOS或者Windows,Linux等OS中,也减轻了开发者的开发负担 对于Java开发者来说,Java减少了开发和部署到多个平台的成本和时间。真正的做到一次编译,到处运行。 有哪些语言实现了平台无关? 所有基于JVM的语言都实现了平台无关,如Groovy、Scala、Jython等 其他的有VM的语言也同样实现了平台无关,如C# 脚本语言:JavaScript,Python,Php Java中基本数据类型的大小都是确定的吗? 非也非也,boolean类型的大小在不同的情况下是不确定的,依据JVM规范第2版: Although the Java virtual machine defines a boolean type, it only provides very limited support for it. There are no Java virtual machine instructions solely dedicated to operations on boolean values. Instead, expressions in the Java programming language that operate on boolean values are compiled to use values of the Java virtual machine int data type. ...

March 22, 2026 · 2 min · santu

Java是编译型还是解释型_

典型回答 我们常用的编程语言,比如C语言、Java、Python、Go等都是高级语言,想要把高级语言转变成计算机认识的机器语言有两种方式,分别是编译和解释。 通常认为编译的过程就是通过编译器(compiler)把高级语言的源代码,直接编译成可以被机器执行的机器码,交由机器执行。如C语言。 而解释的过程就是通过解释器(interpreter)直接解释执行,不需要编译成机器语言。如JavaScript。 其实,现在很多的高级语言,已经很难用简单的"编译型"、“解释型"来区分了,尤其是Java,因为他的代码执行过程并不是单一的。 首先,在Java中,为了实现跨平台和提升运行速度,需要先通过javac将Java源代码编译成字节码,但是这个字节码并不是机器码,计算机没有办法直接执行,需要通过Java虚拟机来解释执行。 但是Java程序在通过解释器进行解释执行的过程中,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后会通过即时编译(JIT)会把部分“热点代码”直接翻译成机器码,而这些机器码就可以被直接执行了。 并且,除了JIT以外,现在Java中也支持AOT编译了,这就是纯纯的编译成机器码。 ✅什么是AOT编译?和JIT有啥区别? 所以,Java语言不能简单的划分成编译型或者解释型,如果非要定义的话,那只能认为,他既是编译型、又是解释型。正常的代码是解释执行的,JIT优化的过程是编译执行的。 知识扩展 编译 在介绍编译,我们先来简单介绍下编程语言(Programming Language)。编程语言(Programming Language)分为低级语言(Low-level Language)和高级语言(High-level Language)。 机器语言(Machine Language)和汇编语言(Assembly Language)属于低级语言,直接用计算机指令编写程序。 而C、C++、Java、Python等属于高级语言,用语句(Statement)编写程序,语句是计算机指令的抽象表示。 举个例子,同样一个语句用C语言、汇编语言和机器语言分别表示如下: 计算机只能对数字做运算,符号、声音、图像在计算机内部都要用数字表示,指令也不例外,上表中的机器语言完全由十六进制数字组成。 最早的程序员都是直接用机器语言编程,但是很麻烦,需要查大量的表格来确定每个数字表示什么意思,编写出来的程序很不直观,而且容易出错,于是有了汇编语言,把机器语言中一组一组的数字用助记符(Mnemonic)表示,直接用这些助记符写出汇编程序,然后让汇编器(Assembler)去查表把助记符替换成数字,也就把汇编语言翻译成了机器语言。 但是,汇编语言用起来同样比较复杂,后面,就衍生出了Java、C、C++等高级语言。 上面提到语言有两种,一种低级语言,一种高级语言。可以这样简单的理解:低级语言是计算机认识的语言、高级语言是程序员认识的语言。 那么如何从高级语言转换成低级语言呢?这个过程其实就是编译。 从上面的例子还可以看出,C语言的语句和低级语言的指令之间不是简单的一一对应关系,一条a=b+1;语句要翻译成三条汇编或机器指令,这个过程称为编译(Compile),由编译器(Compiler)来完成,显然编译器的功能比汇编器要复杂得多。用C语言编写的程序必须经过编译转成机器指令才能被计算机执行,编译需要花一些时间,这是用高级语言编程的一个缺点,然而更多的是优点。首先,用C语言编程更容易,写出来的代码更紧凑,可读性更强,出了错也更容易改正。 将便于人编写、阅读、维护的高级计算机语言所写作的源代码程序,翻译为计算机能解读、运行的低阶机器语言的程序的过程就是编译。负责这一过程的处理的工具叫做编译器。 具体在Java语言中,编译又分为前端编译和后端编译,前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。 我们可以把将**.java**文件编译成**.class**的编译过程称之为前端编译。把将**.class**文件翻译成机器指令的编译过程称之为后端编译。 反编译 反编译的过程与编译刚好相反,就是将已编译好的编程语言还原到未编译的状态,也就是找出程序语言的源代码。就是将机器看得懂的语言转换成程序员可以看得懂的语言。Java语言中的反编译一般指将class文件转换成java文件。 有了反编译工具,我们可以做很多事情,最主要的功能就是有了反编译工具,我们就能读得懂Java编译器生成的字节码。如果你想问读懂字节码有啥用,那么我可以很负责任的告诉你,好处大大的。 Java反编译工具 本文主要介绍3个Java的反编译工具:javap、jad和cfr javap javap是jdk自带的一个工具,可以对代码反编译,也可以查看java编译器生成的字节码。javap和其他两个反编译工具最大的区别是他生成的文件并不是java文件,也不像其他两个工具生成代码那样更容易理解。拿一段简单的代码举例,如我们想分析Java 7中的switch是如何支持String的,我们先有以下可以编译通过的源代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class switchDemoString { public static void main(String[] args) { String str = "world"; switch (str) { case "hello": System.out.println("hello"); break; case "world": System.out.println("world"); break; default: break; } } } 执行以下两个命令: ...

March 22, 2026 · 3 min · santu

Java的堆是如何分代的?为什么分代?

典型回答 Java的堆内存分代是指将不同生命周期的堆内存对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为“代”。这样做有助于提升垃圾回收的效率,因为这样的话就可以为不同的"代"设置不同的回收策略。 一般来说,Java中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。因为如果把这两部分对象放到一起分析和回收,这样效率实在是太低了。通过将不同时期的对象存储在不同的内存池中,就可以节省宝贵的时间和空间,从而改善系统的性能。 **Java的堆由新生代(Young Generation)和老年代(Old Generation)组成。**新生代存放新分配的对象,老年代存放长期存在的对象。 新生代(Young)由年轻区(Eden)、Survivor区组成(From Survivor、To Survivor)。默认情况下,新生代的Eden区和Survivor区的空间大小比例是8:2,可以通过-XX:SurvivorRatio参数调整。 很多对象都会出现在Eden区,当Eden区的内存容量用完的时候,GC会发起,非存活对象会被标记为死亡,存活的对象被移动到Survivor区。 如果Survivor的内存容量也用完,那么存活对象会被移动到老年代。 老年代(Old)是对象存活时间最长的部分,它由单一存活区(Tenured)组成,并且把经历过若干轮GC回收还存活下来的对象移动而来。在老年代中,大部分对象都是活了很久的,所以GC回收它们会很慢。 扩展知识 对象的分代晋升 一般情况下,对象将在新生代进行分配,首先会尝试在Eden区分配对象,当Eden内存耗尽,无法满足新的对象分配请求时,将触发新生代的GC(Young GC、MinorGC),在新生代的GC过程中,没有被回收的对象会从Eden区被搬运到Survivor区,这个过程通常被称为"晋升" 同样的,对象也可能会晋升到老年代,触发条件主要看对象的大小和年龄。对象进入老年代的条件有三个,满足一个就会进入到老年代: 1、躲过15次GC。每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(jdk8默认的),也就是某个对象躲过了15次垃圾回收,那么JVM就认为这个是经常被使用的对象,就没必要再待在年轻代中了。具体的次数可以通过 -XX:MaxTenuringThreshold 来设置在躲过多少次垃圾收集后进去老年代。 2、动态对象年龄判断。规则:如果在Survivor空间中小于等于某个年龄的所有对象大小的总和大于Survivor空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代。 3、大对象直接进入老年代。-XX:PretenureSizeThreshold 来设置大对象的临界值,大于该值的就被认为是大对象,就会直接进入老年代。(PretenureSizeThreshold默认是0,也就是说,默认情况下对象不会提前进入老年代,而是直接在新生代分配。然后就GC次数和基于动态年龄判断来进入老年代。) 针对上面的三点来逐一分析。 动态年龄判断 为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到- XX:M axTenuringThreshold才能晋升老年代,他还有一个动态年龄判断的机制。 在《深入理解Java虚拟机(第三版)》中是这么描述动态年龄判断的过程的: 但是,这段描述是不正确的! JVM中,动态年龄判断的代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100); size_t total = 0; uint age = 1; while (age < table_size) { total += sizes[age]; if (total > desired_survivor_size) break; age++; } uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold; ... } 它的过程是从年龄小的对象开始,不断地累加对象的大小,当年龄达到N时,刚好达到TargetSurvivorRatio这个阈值,那么就把所有年龄大于等于N的对象全部晋升到老年代去! ...

March 22, 2026 · 1 min · santu

JDK1.8和1.9中类加载器有哪些不同

典型回答 从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(只限于HotSpot),是虚拟机自身的一部分; 另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。 从Java开发人员的角度来看,类加载器还可以划分得更细致一些,不过,这里需要区分JDK的版本,在JDK 1.8及之前的版本,和之后的版本是不太一样的。主要是因为JDK 1.9中提供了Jigsaw实现模块化,导致类加载器发生了一些变化。 在JDK1.9中,原来的扩展类加载器被重命名为**平台类加载器—**PlatformClassLoader 。它主要加载的是那些属于Java平台模块系统的非核心模块。这些模块在 module-info.java 文件中被明确声明。 JDK 1.8及之前 JDK 1.8 以前会使用到以下3种系统提供的类加载器。 启动类加载器(Bootstrap ClassLoader): 这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可 扩展类加载器(Extension ClassLoader): 这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。 应用程序类加载器(Application ClassLoader): 这个类加载器由sun.misc.Launcher$App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 JDK 1.9及以后 在JDK 9中,类加载器发生了一些变化,原来的扩展类加载器被重命名为**平台类加载器—**PlatformClassLoader 。 PlatformClassLoader 负责加载JDK平台本身的类库,这些类库位于JDK的 lib 目录下,但不包括核心的 java.* 类库(这些由启动类加载器加载)。它主要加载的是那些属于Java平台模块系统的非核心模块。这些模块在 module-info.java 文件中被明确声明。 扩展知识 类加载器之间的关系 很多人因为知道双亲委派这个词,就以为这些类加载器之间是继承关系,但是其实不是的。他们之间是组合关系。 ✅为什么建议多用组合少用继承? 在ClassLoader类中有一个ClassLoader类型的成员变量,他叫parent,他其实就是代表着当前类的上一层类加载器。 1 2 3 4 5 6 7 public abstract class ClassLoader { // The parent class loader for delegation // Note: VM hardcoded the offset of this field, thus all new fields // must be added *after* it. private final ClassLoader parent; } 这就是通过组合而不是继承引入进来的,当我们想要把一个类委派给上层类加载加载时,直接调用parent.loadClass即可,如 代码: ...

March 22, 2026 · 1 min · santu

JVM 中一次完整的 GC 流程是怎样的?

典型回答 一次完整的GC流程大致如下,基于JDK 1.8: 一般来说,GC的触发是在对象分配过程中,当一个对象在创建时,他会根据他的大小决定是进入年轻代或者老年代。如果他的大小超过-XX:PretenureSizeThreshold就会被认为是大对象,直接进入老年代,否则就会在年轻代进行创建。(PretenureSizeThreshold默认是0,也就是说,默认情况下对象不会提前进入老年代,而是直接在新生代分配。然后就GC次数和基于动态年龄判断来进入老年代。) 在年轻代创建对象,会发生在Eden区,但是这个时候有可能会因为Eden区内存不够,这时候就会尝试触发一次YoungGC。(会在YoungGC前做一次空间分配担保,如果失败可能直接触发FullGC) 年轻代采用的是标记复制算法,主要分为,标记、复制、清除三个步骤,会从GC Root开始进行存活对象的标记,然后把Eden区和Survivor区复制到另外一个Survivor区。然后再把Eden和From Survivor区的对象清理掉。 这个过程,可能会发生两件事情,第一个就是Survivor有可能存不下这些存活的对象,这时候就会进行空间分配担保。如果担保成功了,那么就没什么事儿,正常进行Young GC就行了。但是如果担保失败了,说明老年代可能也不够了,这时候就会触发一次FullGC了。 ✅新生代如果只有一个Eden+一个Survivor可以吗? 还会发生第二件事情就是,在这个过程中,会进行对象的年龄判断,如果他经过一定次数的GC之后,还没有被回收,那么这个对象就会被放到老年代当中去。 而老年代如果不够了,或者担保失败了,那么就会触发老年代的GC,一般来说,现在用的比较多的老年代的垃圾收集器是CMS或者G1,他们采用的都是三色标记法。 也就是分为四个阶段:初始标记、并发标记、重新标记、及并发清理。 老年代在做FullGC之后,如果空间还是不够,那就要触发OOM了。

March 22, 2026 · 1 min · santu

JVM如何保证给对象分配内存过程的线程安全?

典型回答 首先,我们先来梳理下,JVM是如何给对象分配内存的: 如果JIT的逃逸分析后该对象没有逃逸,那么可能优化到栈上分配。 否则对象主要分配到新生代上,如果启动了TLAB,则分配到TLAB中。 如果被判断为大对象,则直接分配到直接进入老年代,譬如很长的字符串和数组,避免为大对象分配内存时由于分配担保机制带来的复制而降低效率 。可以设置-XX:PretenureSizeThreshold,令大于该尺寸的对象直接进入老年代 简而言之,如下图所示: 所以,我们到这里就很清楚了,当给对象分配内存的时候,有可能在栈上分配,这自然不存在线程安全问题。除此之外,如果在堆上分配,则可能会启动TLAB机制,使得堆内存给线程单独划分空间,避免了线程安全的问题。 同时,当不启动TLAB机制的时候,如果一个空间被多个线程同时分配对象,JVM会采用CAS+失败重试的方式来避免线程问题。(具体的CAS机制和其利弊可以移步到JAVA并发专栏) 简而言之,是采用乐观锁的方式,只有假定该堆没有被其他线程操作的时候,当前线程才会在堆上分配对象,如果被其他线程操作,就获取当前堆中的最新标识,然后重试 知识扩展 什么是TLAB TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。 注意到上面的描述中”线程专属”、”只给当前线程使用”、”每个线程单独拥有”的描述了吗? 所以说,因为有了TLAB技术,堆内存并不是完完全全的线程共享,其eden区域中还是有一部分空间是分配给线程独享的。 这里值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。 也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。 并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等 TLAB的缺点 虽然在一定程度上,TLAB大大的提升了对象的分配速度,但是TLAB并不是就没有任何问题的。 前面我们说过,因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况。在《实战Java虚拟机》中有这样一个例子: 比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案: 直接在堆内存中对该对象进行内存分配。 废弃当前TLAB,重新申请TLAB空间再次进行内存分配。 以上两个方案各有利弊,如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。 如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。 为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。 当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。 前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。 当一个TLAB被填满或者废弃时,原有TLAB中的对象不会被移动或复制到新的TLAB中。在JVM中,一旦对象被分配在堆上,它们通常会保持在原地直到被垃圾回收。所以,当一个TLAB用完时,线程会简单地分配一个新的TLAB,并在新的TLAB上继续对象分配。原有TLAB中的对象将保留在其当前位置,直到它们不再被引用并由垃圾收集器回收。

March 22, 2026 · 1 min · santu

JVM是如何创建对象的?

典型回答 首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程 分配内存。JVM会在堆中为对象分配内存空间(无JIT优化情况下)。在HotSpot中,对象的内存分配有两种方式,分别是指针碰撞和空闲列表法。 指针碰撞:当堆中的内存是连续的,JVM使用一个指针来标记当前可用的内存位置,然后将指针向前移动分配对象所需的内存大小。 空闲列表:当堆中的内存是离散的,JVM会维护一个空闲列表,记录可用的内存块。在分配对象时,JVM会遍历空闲列表,找到足够大小的内存块进行分配。 (分配内存解决并发有两种手段,一个是CAS+失败重试,一个是Thread Local Allocation Buffer(TLAB)) 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这一步确保了对象的字段在创建时都有默认值。如int被初始化为0,引用类型被初始化为null 设置对象头。 该实例所对应的类、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄,轻量级锁等等信息 调用该类的构造方法,初始化对象。如按照程序员意愿进行赋值 返回对象引用,当对象完成创建之后,返回一个该对象的引用,后续Java程序就可以使用这个引用来操作对象了。 知识扩展 指针碰撞和空闲列表法的区别? Java在分配内存的时候,会根据堆是否规整来选择指针碰撞法或者空闲列表法 指针碰撞法即是通过一个指针将内存划分为已经分配过的空间和没有分配过的空间,如果要给新对象分配空间,则需要先计算出来该对象应该占用的空间,然后指针向前移动分配对象所需的内存大小,然后返回分配前的指针位置作为对象的起始地址。由此我们可见,指针碰撞法适合堆内存规整的区域,对应着Serial,ParNew GC等垃圾回收器。同时也对应着标记整理算法,复制算法等GC算法 空闲列表法,故名思义,和列表有关。JVM会维护一个列表,其中会记录堆中哪些内存可用,哪些内存不可用,这样在申请内存空间的时候,只需要找到一块可用的内存,并在列表中标记即可。由此可见,该方法适合堆内存不规整,对应的GC算法为标记清除算法。其中CMS GC实现了该算法 指针碰撞适用于堆内存连续的情况,它简单高效,但对内存的要求较高。空闲列表适用于堆内存离散的情况,它可以更灵活地利用内存碎片,但需要额外的空间来维护和查找空闲内存块。 TLAB ✅JVM如何保证给对象分配内存过程的线程安全?

March 22, 2026 · 1 min · santu

JVM有哪些垃圾回收算法?

典型回答 垃圾回收算法指的是JVM用什么样的机制来做垃圾的回收。典型的回收算法有标记-清除算法、复制算法、标记-整理算法。 标记-清除 标记-清除就是将回收过程分为标记、清除两个阶段。 标记:从 GC Roots 对象开始,遍历整个对象图,标记所有存活的对象。(注意,标记的是存活对象,不是垃圾对象) 清除:遍历整个堆内存,回收所有未被标记的(即死亡的)对象所占用的空间。 ✅JVM如何判断对象是否存活? 当 JVM 标记出内存中的标记存活对象后,直接将非存活对象(垃圾)清除,但是这样有一个很明显的缺点,就是会导致内存空间的不连续,也就是会产生很多的内存碎片。先画个图来看下 我们使用上图左边的图来表示垃圾回收之前的样子,黑色的区域表示可以被回收的垃圾对象。这些对象在内存空间中不是连续的。右侧这张图表示是垃圾回收过后的内存的样子。可以很明显的看到里面产生了断断续续的 内存碎片。 那说半天垃圾不是已经被回收了吗?内存碎片就内存碎片呗。又能咋地? 好,我来这么告诉你,现在假设这些内存碎片所占用的空间之和是1 M,现在新创建了一个对象大小就是 1 M,但是很遗憾的是,此时内存空间虽然加起来有 1 M,但是并不是连续的,所以也就无法存放这大对象。也就是说这样势必会造成内存空间的浪费,这就是内存碎片的危害。 比方说其中的1M空间其实依然是可用的,只不过它只能存放<=1M的对象,但是再出现大小完全一模一样的对象是概率很低的事情,即使出现了也并不一定被刚好分配到这段空间上,所以这1M很大概率会被分配给一个<1M的对象,或许只会被利用999K或者1020K或者任意K,剩下的那一点点就很难再被利用了,这才形成了碎片。 这么一说标记-清除就没有优点了吗?优点还是有的:速度快 到此,我们来对标记-清除来做一个简单的优缺点小结: 优点 速度快,因为不需要移动和复制对象 缺点 会产生内存碎片,造成内存的浪费 复制 上面的清除算法真的太差劲了。都不管后来人能不能存放的下,就直接啥也不管的去清除对象。所以升级后就来了复制算法(也有叫标记-复制的)。 复制算法的工作原理是这样子的:首先将内存划分成两个区域。新创建的对象都放在其中一块内存上面,当快满的时候,就将标记出来的存活的对象复制到另一块内存区域中(注意:这些对象在复制的时候其内存空间上是严格排序且连续的),这样就腾出来的那一半就又变成了空闲空间了。依次循环运行。 在回收前将存活的对象复制到另一边去。然后再回收垃圾对象,回收完就类似下面的样子: 如果再来新对象被创建就会放在右边那块内存中,当内存满了,继续将存活对象复制到左边,然后清除掉垃圾对象。 复制算法的明显的缺点就是:浪费了一半的内存,但是优点是不会产生内存碎片。所以我们再做技术的时候经常会走向一个矛盾点地方,那就是:一个新的技术的引入,必然会带来新的问题。 到这里我们来简单小结下标记-复制算法的优缺点: 优点 内存空间是连续的,不会产生内存碎片 缺点 1、浪费了一半的内存空间 2、复制对象会造成性能和时间上的消耗 说到底,似乎这两种垃圾回收算法都不是很好。而且在解决了原有的问题之后,所带来的新的问题也是无法接受的。所以又有了下面的垃圾回收算法。 标记-整理 标记-整理算法是结合了上面两者的特点进行演化而来的。具体的原理和执行流程是这样子的:我们将其分为2个阶段: 第一阶段为标记; 第二阶段为整理; 标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。 ...

March 22, 2026 · 1 min · santu

JVM的运行时内存区域是怎样的?

典型回答 根据Java虚拟机规范的定义,JVM的运行时内存区域主要由Java堆、虚拟机栈、本地方法栈、方法区和程序计数器以及运行时常量池组成。其中堆、方法区以及运行时常量池是线程之间共享的区域,而栈(本地方法栈+虚拟机栈)、程序计数器都是线程独享的。 需要注意的是,上面的这6个区域,是虚拟机规范中定义的,但是在具体的实现上,不同的虚拟机,甚至是同一个虚拟机的不同版本,在实现细节上也是有区别的。 程序计数器:一个只读的存储器,用于记录Java虚拟机正在执行的字节码指令的地址。它是线程私有的,为每个线程维护一个独立的程序计数器,用于指示下一条将要被执行的字节码指令的位置。它保证线程执行一个字节码指令以后,才会去执行下一个字节码指令。 Java虚拟机栈:一种线程私有的存储器,用于存储Java中的局部变量。根据Java虚拟机规范,每次方法调用都会创建一个栈帧,该栈帧用于存储局部变量,操作数栈,动态链接,方法出口等信息。当方法执行完毕之后,这个栈帧就会被弹出,变量作用域就会结束,数据就会从栈中消失。 本地方法栈:本地方法栈是一种特殊的栈,它与Java虚拟机栈有着相同的功能,但是它支持本地代码( Native Code )的执行。本地方法栈中存放本地方法( Native Method )的参数和局部变量,以及其他一些附加信息。这些本地方法一般是用C等本地语言实现的,虚拟机在执行这些方法时就会通过本地方法栈来调用这些本地方法。 Java堆:是存储对象实例的运行时内存区域。它是虚拟机运行时的内存总体的最大的一块,也一直占据着虚拟机内存总量的一大部分。Java堆由Java虚拟机管理,用于存放对象实例,是几乎所有的对象实例都要在上面分配内存。此外,Java堆还用于垃圾回收,虚拟机发现没有被引用的对象时,就会对堆中对象进行垃圾回收,以释放内存空间。 方法区:用于存储已被加载的类信息、常量、静态变量、即时编译后的代码等数据的内存区域。每加载一个类,方法区就会分配一定的内存空间,用于存储该类的相关信息,这部分空间随着需要而动态变化。方法区的具体实现形式可以有多种,比如堆、永久代、元空间等。 运行时常量池:是方法区的一部分。用于存储编译阶段生成的信息,主要有字面量和符号引用常量两类。其中符号引用常量包括了类的全限定名称、字段的名称和描述符、方法的名称和描述符。 ✅运行时常量池和字符串常量池的关系是什么? 扩展知识 堆和栈的区别 堆和栈是Java程序运行过程中主要存储区域,经常被拿来对比,他们主要有以下区别(这里的栈主要指的是虚拟机栈): 1、存储位置不同,堆是在JVM堆内存中分配空间,而栈是在JVM的栈内存中分配空间。 2、存储的内容不同,堆中主要存储对象,栈中主要存储本地变量 3、堆是线程共享的,栈是线程独享的。 4、堆是垃圾回收的主要区域,不再引用这个对象,会被垃圾回收机制自动回收。栈的内存使用是一种先进后出的机制,栈中的变量会在程序执行完毕后自动释放 5、栈的大小比堆要小的多,一般是几百到几千字节 6、栈的存储速度比堆快,代码执行效率高 7、堆上会发生OutofMemoryError,栈上会发生StackOverflowError ✅OutOfMemory和StackOverflow的区别是什么 Java中的对象一定在堆上分配内存吗? Java中的对象一定在堆上分配内存吗? 什么是堆外内存? 堆外内存是指将数据存储在堆以外的内存中,主要是指将一些直接内存分配在堆以外,以提高应用程序的性能或节省堆内存空间。堆外内存可以用于分配容量较大的缓冲区,比如文件缓冲区等等。 Java 中的直接内存是通过使用 java.nio 包中的 DirectByteBuffer 类来实现的。DirectByteBuffer 类可以用来分配本地内存,并允许在 Java 和本地内存之间交换数据。使用 DirectByteBuffer 可以减少从 Java 堆和本地堆之间进行复制和读写的开销,从而提升程序的性能。 方法区的变迁 前面我们提过,方法区其实是JVM规范中定义出来的一块区域,具体的虚拟机实现上是有很大的差别的。在不同的版本中,方法区的位置也不尽相同。 什么是方法区?是如何实现的?

March 22, 2026 · 1 min · santu

留言给博主