ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从类到对象:JVM类加载、内存分配与GC回收全解析

从类到对象:JVM类加载、内存分配与GC回收全解析 刚入行那会儿我也把“类是图纸对象是实物”这句话挂在嘴边。直到有一次面试官反问“那图纸什么时候被加载进内存实物是在哪一块区域浇铸出来的GC又是靠什么判断这件实物该扔了”我才发现光有比喻远远不够。这篇就把从“类”到“对象”再到“JVM内存模型”的完整链路拆开讲一遍覆盖类定义、类加载、对象创建、内存区域划分、GC回收以及面试里围绕这些概念衍生出来的高频追问。想弄清这些问题的人不管是准备Java面试、刚学完基础仍对内存心存疑惑还是想系统梳理对象模型的老手都能从这里找到能直接用的理解方式。1. 图纸不是一张类其实是一个“套餐”1.1 类里面装着什么字段、方法、构造器与静态成员很多人把类理解成“字段 方法的集合”方向没错但不够完整。一个普通类里其实打包了四类东西描述状态的实例字段、描述行为的实例方法、负责初始化对象的构造器还有独立于任何对象的静态成员。看一个最常见的例子public class User { private Long id; private String name; private Address address; public User(Long id, String name) { this.id id; this.name name; } public String getDisplayName() { return name null ? 匿名用户 : name; } public static User createGuest() { return new User(null, 游客); } }这里id、name、address是实例字段每个User对象都有自己的副本getDisplayName()是实例方法它操作的是具体某个对象的数据构造器User(Long, String)决定了对象创建时字段怎么初始化而createGuest()是静态方法不依赖实例可以直接通过User.createGuest()调用。理解这四类成员的区别是理解内存模型的第一块地基。实例字段要跟着对象走所以存在堆里静态成员属于类的在类初始化阶段就要准备好留在方法区或元空间构造器不是普通方法它在对象分配之后被调用用来把一块“刚切出来的内存”变成一个有意义的对象。我在带新人的时候经常问一个问题静态方法里能不能直接访问实例字段答案显而易见不能但很多人只是背结论。真正原因是静态方法没有this引用它不知道该去操作堆里的哪一个对象。反过来实例方法里可以访问静态成员因为实例方法能通过类信息找到静态成员所在的位置。1.2 抽象类、接口、普通类三种“图纸规格”的差别“抽象类和普通类的区别”几乎是Java面试的送分题但真正能讲明白的人不多。普通类可以实例化抽象类不能直接new抽象类可以声明抽象方法也可以有已经实现的方法和构造器普通类则没有抽象方法。设计抽象类本质上是为了把“不变的部分实现掉把变化的部分留给子类”。接口在Java 8之后也变复杂了。默认方法让接口有了方法体静态方法也让接口可以承载工具方法但接口依然没有实例字段除了public static final常量依然不能有构造器。我个人的选择标准很简单如果只是定义行为契约用接口如果需要抽取公共状态和通用逻辑用抽象类如果多个实现之间存在大量重复代码抽象类更合适否则接口更灵活。内部类也是高频考点。Java的内部类分四种静态内部类、成员内部类、局部内部类、匿名内部类。最容易踩坑的是成员内部类会隐式持有外部类实例的引用这在Android开发或长生命周期组件里是内存泄漏的重灾区。静态内部类不持有外部类引用所以Builder模式、Entry这类对象都用静态内部类。局部内部类和匿名内部类在方法栈帧里存在但对象本身还是分配在堆上方法结束只是引用消失对象要等GC回收。画类图的时候不同关系用不同箭头。用StarUML画透视图时要注意继承是空心三角加实线实现是空心三角加虚线依赖是普通虚线箭头关联是实线箭头聚合是空心菱形加箭头组合是实心菱形加箭头。这几类关系对应到Java代码里依赖是方法参数、关联是成员变量、聚合和组合是成员变量但生命周期强弱不同继承和实现则是类声明层面的关系别把箭头画错。2. new 一个对象到底发生了什么2.1 类加载把图纸读进内存很多人以为new User()只是分配一块内存然后跑构造器实际上在此之前还有一个隐藏环节类加载。如果User类还没有被加载过JVM要先把User.class这个字节码文件读进来经过加载、验证、准备、解析、初始化五个阶段最终在方法区HotSpot里是元空间生成对应的Class对象。加载阶段通过类全限定名找到字节码文件并读入验证阶段检查字节码是否符合规范准备阶段为静态变量分配内存并赋默认值比如static int count在这时已经是0解析阶段把常量池里的符号引用替换成直接引用。初始化阶段才执行静态代码块和静态变量赋值也就是clinit方法。这里有个常见的错误认知“Java是静态链接的”。Java其实是典型的动态链接语言符号引用在运行时解析类按需加载甚至可以延迟到真正使用的时候。所以“全部类在启动时就链接完”的说法对Java不成立这也是Java容器能实现热部署、动态替换类的基础。面试里讲到类加载能脱口而出“加载-验证-准备-解析-初始化”是基本分能说出准备阶段和初始化阶段分别做了什么事才是加分项。2.2 字节码视角new、dup、invokespecial 的三步舞写一行代码User user new User(1L, 张三);表面上是一句话字节码层面其实是四步// 伪字节码示意 new User // 1. 在堆上分配空间对象处于半初始化状态 dup // 2. 复制一个引用供后续构造器使用 invokespecial User.init // 3. 调用构造器完成初始化 astore_1 // 4. 把引用存入局部变量表关键在第一步和第三步之间。new之后、invokespecial之前对象已经被分配好但字段还是默认值构造器还没跑完。如果此时有别的线程拿到了这个半初始化对象的引用就会看到一个“不完整”的对象。这也是双重检查锁单例为什么需要volatile修饰实例字段的原因防止构造器中的指令重排序导致另一个线程读到半初始化对象。再往下挖一层构造器里调用可以被重写的方法是个隐蔽陷阱。因为Java是动态分派的如果父类构造器里调用了子类重写的方法而子类自己的字段还没初始化就会出现拿到null或者默认值的情况。我当年在一个报表系统里就踩过基类构造器里调用了buildTitle()子类重写后依赖自己的titleMap字段结果titleMap恒为null。这种问题不查半天根本反应不过来。2.3 对象在堆里的长相一个Java对象在堆内存里不是光秃秃的数据它的布局由三部分组成对象头、实例数据、对齐填充。对象头最重要HotSpot虚拟机里通常包含两部分Mark Word和Klass Pointer。Mark Word存储对象的哈希码、GC分代年龄、锁状态等信息高并发下加锁优化的状态就记录在这里。Klass Pointer指向类元数据告诉JVM这个对象到底是什么类型。如果对象是数组对象头里还会多一个数组长度字段。实例数据就是你在类里声明的字段。字段排列顺序受虚拟机的“分配策略”影响HotSpot会尽量按从大到小的顺序排列把引用类型排在一起。为了减少内存碎片对象的整体大小要是8字节的整数倍不够就补齐这就是对齐填充。64位虚拟机开了压缩指针之后类指针从8字节压缩到4字节能省不少内存。还有个反直觉的点对象不一定总在堆上。现代JVM在做逃逸分析之后如果对象只在当前方法内部使用、没有逃逸出线程可能做栈上分配甚至标量替换也就是直接把对象的字段拆成局部变量。这就是为什么某些性能测试下创建大量小对象的代码也没有想象中那么慢。3. JVM内存模型对象住在哪、怎么搬家3.1 运行时数据区一张内存地图聊JVM内存模型的时候首先要区分两个概念一个是JVM运行时数据区解决“对象分配在哪”另一个是Java内存模型Java Memory ModelJMM解决“多线程下变量修改何时可见”。很多面试者把这两个混在一起答一开口就崩了。这里讲的是前者。运行时数据区可以画成一张表区域线程私有还是共享存什么异常表现常见参数程序计数器私有当前线程执行的字节码行号无无虚拟机栈私有栈帧存局部变量表、操作数栈、方法返回地址StackOverflowError-Xss本地方法栈私有本地方法调用StackOverflowError-XssJava堆共享对象实例、数组OutOfMemoryError: Java heap space-Xms、-Xmx方法区/元空间共享类元信息、静态变量、常量池OutOfMemoryError: Metaspace-XX:MaxMetaspaceSize程序计数器是唯一不会OOM的区域因为每条线程只需要一个很小的行号记录。虚拟机栈和本地方法栈溢出通常表现为无限递归栈帧一层层压下去最后StackOverflowError。堆是对象的主要居住地new User()出来的对象就落在这里。元空间在JDK 8之后取代了永久代存的是类元信息字符串常量池被挪到了堆里。3.2 堆内分代对象的一辈子堆不能是一锅粥否则每次GC都要扫描全堆。HotSpot把堆分成新生代和老年代新生代内部又拆成Eden区和两个Survivor区S0、S1默认比例大致是8:1:1。新对象通常出生在Eden区。Minor GC发生时Eden区存活的、以及Survivor区里还活着的对象会被拷贝到一个空的Survivor区然后清空Eden和另一个Survivor。每经历一次Minor GC还活着对象的年龄就加一达到阈值默认15就晋升到老年代。大对象比如特别长的数组、大量字符串会直接进老年代避免在新生代来回拷贝。判断对象能不能回收靠的是可达性分析不是引用计数。引用计数解决不了循环引用问题A引用B、B引用A谁都不再被别人引用时引用计数还是1永远清不掉。可达性分析从GC Roots出发往下遍历能到达的对象就是存活的到达不了的就标记为可回收。GC Roots包括虚拟机栈里的局部变量引用、静态变量引用、JNI引用、常量池里的引用等。这也是为什么我们总说栈帧里的局部变量不再使用后即使还有引用在位只要把它置为null对象就可能提前被回收。引用类型也分强弱强引用只要还在GC就不可能回收软引用在内存不足时才回收适合做缓存弱引用遇到GC就回收适合做WeakHashMap这类短生命周期关联虚引用主要用来跟踪对象被回收的状态。面试时被问“如何判断对象为空、如何避免内存泄漏”本质就是在考SoftReference、WeakReference的设计动机。3.3 “判断对象为空”到底在判断什么热搜词里“判断对象为空”出现频率很高但很多人没想清楚判断空分两种一种是引用为null一种是对象存在但内部没有内容。Java里判断引用为null直接obj null就行有人为了优雅用Objects.isNull(obj)本质一样。判断字符串空用str null || str.isEmpty()JDK 11之后用isBlank()还能把全空白的也识别出来。判断集合空用CollectionUtils.isEmpty(list)它会先判引用是否为null再判size()0。判断Map空同理。真正高级的做法是引入“空对象模式”用Collections.emptyList()或自定义一个NullUser对象避免到处返回null把分支判断转移到类型系统里。这个思路在很多框架的默认实现里很常见。前端同学常说的“判断是不是空对象 JS”和Java的哲学不一样JS用Object.keys(obj).length 0它判断的是对象本身没有可枚举属性。对比着看Java的判空更强调“引用是否存在”和“容器是否有内容”两个维度的分离。4. 面试高频题从类到对象的实战拷问4.1 “new 一个对象的过程”怎么答才完整这道题在Java面试题里出现频率极高答案有层次。最基本的一条线是类加载如果类还没被加载先加载并初始化类执行clinit。第二条线是内存分配在堆的Eden区划出一块空间大小由对象布局决定对象头、字段、对齐填充都算进去。第三条线是构造器调用init方法执行字段默认初始化、显式初始化、构造代码块和构造器里的代码。能再说一层就更好了。比如提到TLABThread Local Allocation Buffer每个线程在Eden区有自己的一块私有分配区域避免多线程分配时的锁竞争提到指针压缩64位下如何省内存提到逃逸分析对象不一定在堆上。最后一层收在“半初始化对象”上用synchronized双重检查锁和volatile为什么配合使用来证明你真的理解了这个过程。4.2 工具类与基础API中容易翻车的细节StringTokenizer老牌工具类很多人已经不用了但它还常出现在笔试题里。它和split的差别是StringTokenizer按字符逐个分割性能上更原始但它不是正则不会处理转义split接收的是正则表达式功能强得多。用于简单固定分隔符场景比如CSV按逗号拆分StringTokenizer依然有存在空间。Math类设计成不能被实例化因为所有方法都是静态的。Math.abs、Math.max、Math.sqrt这类纯函数不依赖状态设计成静态方法合理。同样道理的还有Collections、Objects这些工具类构造函数都是private这是“工具类应该不能实例化”的设计约束。很多新手看到编译报错“表达式必须包含类类型”直接懵。其实这个错误多半是把类名当实例用比如你写String.length()编译器会告诉你“表达式必须包含类类型”因为length()是实例方法应该用某个String对象去调。反过来String.valueOf(1)没问题因为valueOf是静态方法。区分“类名点号调用”和“对象名点号调用”是做Java基础题必须过的一关。对象数组去重也是一道经典题。[user1, user2, user3]去重如果只是去重复引用用LinkedHashSet就够了如果是按业务字段去重比如按userId去重就必须重写equals()和hashCode()否则两个字段值相同的对象在Set眼里还是不同的。这里隐藏一个硬约束重写equals就一定要重写hashCode否则HashMap、HashSet这些散列容器会出现逻辑错误。排序同理用Comparator.comparing(User::getAge)不要自己手写一堆if-else比较。冒泡排序作为算法题怎么写都能过但工程里对象排序永远优先用工具方法。4.3 其他语言里的“对象”能反衬Java的设计光看Java理解不透对象多看点别的语言有奇效。热搜词里“js中函数是对象吗”这个问题特别说明问题。在JavaScript里函数是Function类型的实例可以赋值给变量、当参数传递也可以携带属性所以才有高阶函数这种玩法。Java不把方法当对象但用函数式接口Function、Predicate配合Lambda来模拟类似能力本质上是在用“接口对象”包装行为。Python走得更远一切皆对象连类本身都是type类创建的实例所以才会有元类、装饰器这些东西。PHP里“接口数组对象”的说法很形象数组和对象经常相互转换灵活性高但类型安全弱。Django里的Model.objects.get(id1).delete()也很有意思它在ORM层把“查询对象”和“删除操作”封装在一起底层还是SQL但上层让你操作的是对象关系映射后的Python对象。前端框架uni-app的Vue 3里ref号称万能对象把基本类型包装成响应式对象底层是Proxy代理jQuery里$(input[namexxx])拿到的也是jQuery对象是对原生DOM的封装。看完这些再回头看Java的“引用即句柄”回过头来就懂为什么a b比的是引用而不是数据本身了。5. 从对象模型到真实项目建模、映射与权限5.1 UML类图把对象关系画明白对象之间的关系如果画不清楚写的代码大概率也是糊涂的。我实践下来的判断顺序是先看方法里有没有用到别的类有就是依赖再看类里有没有持有别的类作为成员变量有就是关联如果成员变量的生命周期和整体强绑定比如Order包含OrderItem且Order删除后OrderItem没有单独存在意义就是组合实心菱形如果整体消失后部分还能独立存在比如Team和Member就是聚合空心菱形最后再看继承和实现那是抽象层次的约束。用StarUML画类图时先画类框再从上往下加字段和方法然后拖箭头连接关系。画聚合和组合时经常搞反核心判断就是一句话“部分能不能脱离整体存活”。能存活是聚合不能存活是组合。这个点在工作里能帮忙选出正确的对象模型比如一个商城系统里商品分类删掉了分类下的商品不能跟着没所以分类和商品之间是聚合订单删了订单明细就没有意义所以订单和明细之间是组合。5.2 多商户跨境商城里的对象设计热搜词里出现“spring boot mybatis 的 java 开源多商户跨境商城源码下载”说明很多人想通过现成项目学对象建模。我的建议是源码可以下但重点看两件事实体关系模型和行级权限。多商户系统里租户隔离是靠对象模型里的“租户字段”完成的。商户、商品、订单全部带tenant_id查询时MyBatis拦截器自动在SQL后拼上AND tenant_id ?而不是每个业务代码手动写这样才不容易漏。这个设计在百度、阿里内部都是通用做法面试时能把这个讲出来含金量直接拉满。对象映射到数据库表是另一个大话题。Java对象里有BigDecimalMySQL里就要对应DECIMALJava的LocalDateTime对应DATETIME枚举字段可以用EnumType.STRING还是ORDINAL都要显式决定。别用浮点型存金额这是所有电商项目的铁律。热搜词里还有“阿里json.parsearray转换对象有两万行扛得住吗”这其实是对象批量解析的性能问题。JSON.parseArray把两万行JSON转成两万个Java对象每个对象又带着字符串和嵌套对象内存占用可能轻松到几十MB甚至上百MB。处理这种事我一般先算估算内存大列表拆成分批处理能只解析必要字段就只映射必要字段别一口气全塞进内存。5.3 警惕对象设计里的内存黑洞对象设计得好不好直接反映在内存占用上。最经典的问题是内部类隐式持外引用。一个匿名内部类被放入静态集合等于把外部Activity或Service实例也一并存活这是Android内存泄漏的经典套路。换个思路能用静态内部类就尽量用静态内部类这样它不会对外部实例“拉帮结派”。大字符串、大数组最容易吃爆堆。比如日志系统里把一个超大请求体转成String再转成JSON对象瞬间冒出一个几十MB的大对象还会直接进老年代加剧Full GC。我处理过的一个生产事故就是对象里嵌套了一个ListString某个用户的数据把列表撑到几十万条接口直接OOM。后来改成从库里分页拿出来在内存里只保留摘要对象问题才消失。6. 常见问题与排查技巧实录6.1 三个溢出异常怎么看先说StackOverflowError。多半是无限递归调用栈一层层弹上去栈帧压爆了。排查时先看报错栈顶的重复类名基本就是那几行代码。单位时间内的递归、正则回溯、循环方法调用都可能是诱因。用-Xss调大栈空间只适合临时缓解准确做法是找递归边界。再说OutOfMemoryError: Java heap space。堆满了对象只进不出。第一步用jmap -dump:formatb,fileheap.hprof pid导堆转储然后分析对象分布找出谁的实例数异常再看它被什么强引用连在GC Roots上。排查过一次Gateway服务OOM最后发现是内存缓存做成了强引用Map数据越积越多换成带过期淘汰的缓存容器就消停了。元空间溢出在动态生成类的场景里很常见比如大量用字节码框架动态代理、JSP编译、反射频繁生成新类。此时看响应报错提示和类加载器数量必要时限制-XX:MaxMetaspaceSize但根治办法是少生成无意义的类。6.2 对象操作翻车现场用比较对象是最常见的低错。两个字符串内容一样但引用不同为false。Integer也有缓存陷阱-128到127之间缓存比较相等超出范围就是两个不同对象。网上对“为什么阿里巴巴编程规约强制用equals比较”的讨论够多了我不重复只说教训我见过真有人拿BigDecimal的比金额线上少算一分钱都算不完账。BigDecimal必须用compareTo因为它还区分0.00和0的保留精度。HashMap里对象字段改了也要注意。HashMap用hashCode()定位桶如果对象放进Map后参与hashCode的字段变了它的哈希值也跟着变再get就找不到原来的值了。解决方法是放进集合的对象尽量设计成不可变业务上要改就走“重新创建对象再放进去”的路线。翻车率较高的还有JSON反序列化。我遇到过JSON.parseObject把客户传的0.10按Float解析结果精度损失后面计算全部飘掉。DTO字段的类型必须和上游数据约定好涉及金额、百分比的一律用BigDecimal或String接收再转换不能图省事。6.3 高频面试题速查表面试题核心回答要点加分项类与对象的关系类是抽象定义对象是具体实例用内存模型说明类元信息在元空间对象在堆new一个对象的过程类加载、分配内存、执行构造器提到TLAB、半初始化对象、逃逸分析抽象类和接口的区别抽象类有构造器、可有状态、单继承接口多实现、行为契约Java 8默认方法与静态方法静态方法能否调实例方法不能因为没有this引用从字节码角度解释局部变量表JVM内存模型运行时数据区五大块堆栈方法区职责区分JVM内存模型与Java内存模型对象什么时候可回收可达性分析不可达才回收GC Roots是什么、引用类型分类如何判断对象为空引用为null与对象内容为空分开判断空对象模式、Optional的使用边界hashCode和equals为什么必须一起重写散列容器先比较hashCode再equals举例HashMap存取失效6.4 内存模型优化中性价比最高的几件事和“gcjava内存模型优化”相关的文章很多但我见过的多数建议空泛。按我个人的经验性价比排序大致是这样第一避免大对象进老年代比如超长字符串、大数组、超大List第二谨慎持有静态集合静态Map里只放弱引用或设置容量上限第三批量数据处理分页不要一次把数据库结果全部映射成对象第四合理设置堆大小-Xms和-Xmx设一样避免动态扩容抖动但不要盲目给大第五减少不必要的对象创建字符串拼接用StringBuilder能复用对象就复用。这些动作不用改多少代码效果却比调一个什么参数都明显。排查内存问题时我的日常起手式是jps找到进程jstat -gcutil pid 1000看GC曲线jstack看线程状态再决定要不要上jmap导出堆分析。现在有Arthas这类工具更方便可以线上直接dashboard、thread、heapdump省去很多重启的麻烦。我个人在这些年踩过最深的一次坑是核心结算服务里对象建模阶段没有区分“值对象”和“引用对象”把可变对象当Map的Key用改了一个字段后整个缓存命中率掉到谷底。从那以后我养成了画内存分布图的习惯每写一个复杂的类结构先在草稿纸上把“谁引用谁”标出来。类与对象、内存模型这些知识看起来是面试八股但项目一旦动起来它们就是排查问题的直觉来源。你真正看懂了一个对象从图纸变成实物的全部路径很多线上诡异问题都不再是运气题。
返回列表