拆箱问题的本质是有性能问题,以及会导致潜在的NPE
- 因为原理是调用了.longValue方法,特别是针对包装类的数字类型
解决方法就是用comparingLong
这些就是对拆箱装箱的深入了解,会涉及到JVM的堆内存和栈内存
- 具体的例子就是comparing和comparingLong
priceList.sort(Comparator.comparing(xxxx::getSkuId));
priceList.sort(Comparator.comparingLong(xxxx::getSkuId));
参考文档
直接回答结论:会有拆箱问题,但绝大多数业务场景下,这个性能损耗可以忽略不计。只有在极端大数据量下,它才会成为性能黑洞。
为了让你彻底弄懂,我们扒开 Comparator.comparing 的底裤看看它到底是怎么运作的。
一、 拆箱是怎么发生的?(底层原理推演)
假设你的 getSkuId() 返回的是 Long(包装类)。
当你写下这行代码时:
java
Comparator.comparing(xxx::getSkuId)
Java 编译器会在背后悄悄干这样的事:
-
推断泛型 :
comparing方法签名是Comparator.comparing(Function<? super T, ? extends U> keyExtractor)。因为你的getSkuId()返回Long,所以U被推断为Long。 -
强转 Comparator :此时,返回的是一个
Comparator<T>,也就是比较Long对象的比较器。 -
拆箱比较 :在排序时,底层逻辑等价于执行:
javaLong skuId1 = obj1.getSkuId(); // 从对象里拿到 Long Long skuId2 = obj2.getSkuId(); // 从对象里拿到 Long // 关键点来了:Long 的比较,底层最终会调用 longValue() return skuId1.compareTo(skuId2);而
Long.compareTo的源码是这样的:javapublic int compareTo(Long anotherLong) { return compare(this.longValue(), anotherLong.longValue()); // 发生拆箱! }这里的
this.longValue()和anotherLong.longValue()就是把Long对象拆成了基本类型long放在操作数栈上。
总结: 只要你的 get 方法返回的是包装类(Integer, Long 等),使用 Comparator.comparing,在每次比较时必定会触发拆箱。
二、 拆箱的代价到底有多大?(量化分析)
我们以 10 万条数据为例,估算一下拆箱的消耗:
- 比较次数 :TimSort 算法的时间复杂度是 O(NlogN)O(N \log N)O(NlogN),10 万条数据大约需要比较 150 万次 (100000×13.8100000 \times 13.8100000×13.8)。
- 拆箱操作 :每次比较涉及 2 个对象,意味着要调用大约 300 万次
Long.longValue()。 - 性能损耗 :
longValue()本身只是返回内部的一个value字段,单次执行极快(纳秒级)。300 万次执行大约额外消耗 几毫秒到十几毫秒。
结论: 对于日常 CRUD 中几十、几百、甚至一两万条数据的内存排序,拆箱消耗的那点时间,在整体业务逻辑中连个水花都算不上,完全不需要优化。
三、 什么时候需要优化?怎么优化?
什么时候必须优化?
- 数据量极大:比如单次排序超过 10 万、甚至百万级数据。
- 高频调用:这个排序逻辑在一个 QPS 极高的接口里,每秒被调用成百上千次。此时,"滴水成河",GC 压力和 CPU 消耗就会被放大。
怎么优化?避免拆箱!
JDK 开发者早就意识到这个问题了,所以在 Java 8 中,除了 Comparator.comparing(),还提供了针对基础类型的特定方法:
Comparator.comparingInt(...)Comparator.comparingLong(...)Comparator.comparingDouble(...)
优化写法:
如果你的 skuId 实际上可以用 long 处理,你可以这样写:
java
// 错误写法:会有拆箱
priceList.sort(Comparator.comparing(xxx::getSkuId));
// 正确写法:强制走 long 基础类型比较通道,零拆箱
priceList.sort(Comparator.comparingLong(xxx::getSkuId));
为什么 comparingLong 没有拆箱?
它的底层不再使用 Function 提取对象,而是使用 ToLongFunction:
java
// comparingLong 源码内幕
public static <T> Comparator<T> comparingLong(ToLongFunction<? super T> keyExtractor) {
Objects.requireNonNull(keyExtractor);
return (c1, c2) -> Long.compare(keyExtractor.applyAsLong(c1), keyExtractor.applyAsLong(c2));
}
看到了吗?keyExtractor.applyAsLong(c1) 直接把提取和比较都压在 long 这个基本类型上了,从头到尾 Long 对象根本没机会现身,彻底消灭了拆箱操作。
总结陈词
Comparator.comparing配合包装类,确实存在拆箱问题。- 日常业务小数据量排序,请继续用它,代码可读性最高,不要为了芝麻丢西瓜。
- 如果数据量极大或处于性能敏感的核心链路,请果断替换为
comparingLong/comparingInt,这是高手该有的细节控。
拆箱的性能消耗问题
你抓到了最核心的点!从宏观的语义上看,你说得完全正确:这两个方法最终都把 Long 对象变成了 long 基本类型,这个"剥壳"的动作确实都发生了,而且目的也相同。
如果仅仅停留在"有没有剥壳"这个层面,两者确实没有区别。
但为什么我们在性能优化时,死咬着 comparing 说不放,却说 comparingLong 没问题?因为"拆箱发生的位置"不同,导致了底层内存分配和垃圾回收(GC)的天壤之别。
为了让你感受到这个位置的致命差异,我们必须深入到 JVM 的内存分配层面来看。
关键差异:拆出来的 long 存在哪?
这是理解一切的关键。拆箱拆出来的那个裸奔的 long,到底放在内存的哪个区域?
场景 1:comparing 的拆箱 ------ 拆在了"堆"里(极度浪费)
当你使用 comparing 时,底层的执行逻辑等价于这样:
java
// 伪代码:comparing 的底层逻辑
Long obj1 = vo1.getSkuId(); // 从对象取出 Long
Long obj2 = vo2.getSkuId();
// 关键点:compareTo 是对象方法,必须基于对象实例执行
return obj1.compareTo(obj2);
在 compareTo 内部发生拆箱时,逻辑是这样的:
java
// Long.compareTo 的真实源码
public int compareTo(Long anotherLong) {
// this 和 anotherLong 都是存在于【堆内存】中的 Long 对象
return compare(this.longValue(), anotherLong.longValue());
}
发现盲点了吗?
虽然 this.longValue() 把值取出来了,但 this 这个 Long 对象本身依然死死地躺在堆内存里!
在排序的十几万次比较中,JVM 需要不断持有这些 Long 对象的引用,去调用它们的方法。这些 Long 对象占据了堆内存,比较结束后又成了垃圾,等着 GC(垃圾回收器)来清理。这就叫拆箱的性能开销:不仅耗 CPU 去调用方法,更耗内存,还制造 GC 压力。
场景 2:comparingLong 的拆箱 ------ 拆在了"栈"里(零开销)
我们再来看看 comparingLong 的底层逻辑:
java
// comparingLong 的底层逻辑
// 注意:ToLongFunction.applyAsLong() 直接返回 long 基本类型
long val1 = vo1.getSkuId().longValue(); // 在提取的瞬间,拆箱!
long val2 = vo2.getSkuId().longValue();
// 关键点:Long.compare 是静态方法,直接处理基本类型
return Long.compare(val1, val2);
这里的拆箱,发生在 getSkuId().longValue() 这一步。
当这一步执行完,那个裸奔的 long 被取出来后,JVM 立刻就把那个 Long 对象给遗忘了!
取出来的 val1 和 val2 是基本类型,它们不会存在堆内存里,而是直接压入了**当前线程的栈(操作数栈/局部变量表)**中。
栈内存的特点是:方法执行完,空间立刻释放,不需要 GC 垃圾回收器参与,也不占用堆内存。
进入 Long.compare(val1, val2) 时,全都是基本类型运算,像流水线一样极快,没有任何对象拖累。
终极通俗比喻:搬砖与丢垃圾
假设 Long 是带包装纸的砖头 ,long 是裸砖。我们要对 10 万块砖头进行排序比较。
-
comparing的做法(堆内存拆箱) :你把 10 万块带包装的砖头 全拉到工地上(堆内存)。每次比较时,你当场面撕开两块砖的包装纸(调用
compareTo拆箱),比完重量后,砖头和满地的废包装纸依然堆在工地上。直到全部比完,才叫垃圾车(GC)来清理满地的包装纸。工地拥挤不堪,效率极低。 -
comparingLong的做法(栈内存拆箱) :你在砖厂门口设了个安检(
applyAsLong提取器)。每块砖进门时,立刻撕掉包装纸扔在门外(瞬间拆箱,Long对象废弃) ,只把裸砖 扔进工地(压入栈内存)。工地上只有紧凑的裸砖,比完直接走人,根本不需要叫垃圾车清理包装纸,工地宽敞,运转飞快。
回到你的疑问
这两个不都进行了拆箱吗,只是拆箱的位置不一样
是的,从逻辑上讲,都是把包装剥了。
但因为位置不同:
- 在
compareTo里面拆(comparing),对象主体还在堆里占着内存,拆出来的值还得依附于对象,产生了垃圾(废包装纸),需要 GC 打扫。 - 在提取器里面拆(
comparingLong),对象当场销毁,拆出来的值直接进栈,不产生需要 GC 打扫的垃圾。
所以,当我们说 comparingLong "没有拆箱问题"时,更严谨的表述应该是:comparingLong 巧妙地将拆箱时机提前,避免了在堆内存中产生大量中间包装对象,从而彻底消灭了由拆箱引发的内存占用和 GC 性能问题。
这是一个极其硬核且直击 JVM 本质的好问题!
要弄清楚到底放在哪了,我们不能靠猜,得从 Java 的语法规则 和 JVM 的字节码执行机制 两个维度来实锤。
我带你拿"放大镜"看底层的细节。
第一层实锤:Java 语法层面的铁律 ------ 对象堆,基本栈
这是 Java 虚拟机规范定死的规矩:
Long是对象 :凡是new出来的对象(包括Long包装类),必须分配在堆内存中。方法执行时,栈里只存一个指向堆对象的引用(指针)。long是基本类型 :基本类型,通常分配在栈内存中(具体说是当前方法的局部变量表或操作数栈),速度极快,方法结束自动释放。
记住这两条铁律,我们来看两种 comparing 的代码到底生成了什么。
第二层实锤:看代码执行时,到底持有的是什么
1. comparing:持有了堆上的对象引用
java
// comparing 的核心逻辑
return (c1, c2) -> {
Long key1 = c1.getSkuId(); // 得到 Long 对象
Long key2 = c2.getSkuId(); // 得到 Long 对象
return key1.compareTo(key2);
};
在这段代码中,key1 和 key2 是什么?是 Long 对象的引用 。
由于 key1 和 key2 还要参与后面的 compareTo 方法调用,所以 JVM 必须 保持着对这些 Long 对象的强引用。这些 Long 对象安安稳稳地待在堆内存里,直到 compareTo 方法执行完毕,这些对象才可能在后续被 GC 回收。
2. comparingLong:只持有栈上的基本类型
java
// comparingLong 的核心逻辑
return (c1, c2) -> {
long key1 = c1.getSkuId().longValue(); // 瞬间拆箱,得到 long 基本类型
long key2 = c2.getSkuId().longValue(); // 瞬间拆箱,得到 long 基本类型
return Long.compare(key1, key2);
};
在这段代码中,c1.getSkuId() 确实瞬间在堆里拿到了一个 Long 对象,但紧接着 .longValue() 执行了拆箱。
拆箱的 JVM 指令,本质上是去堆对象里把那个 value 字段的数字拷贝出来。
拷贝出来后,赋值给了 key1。此时 key1 是 long 基本类型,它直接存放在栈帧的操作数栈/局部变量表 中。
而刚才那个临时产生的 Long 对象,因为没有任何引用指向它了,瞬间变成了可回收的垃圾(甚至在极端情况下,由于是 Integer 缓存池里的对象,连回收都不需要)。
后续的 Long.compare(key1, key2) 全程是在栈上对两个数字进行运算,与堆内存毫无瓜葛。
第三层实锤:用字节码(Bytecode)抓现行
百闻不如一见,我们直接看 JVM 编译后的字节码指令。这是最无法反驳的证据。
我们写一个测试类:
java
public class BoxTest {
// comparing 模式
public int compareWithObject(Long a, Long b) {
return a.compareTo(b);
}
// comparingLong 模式
public int compareWithPrimitive(long a, long b) {
return Long.compare(a, b);
}
}
用 javap -c BoxTest 查看字节码:
compareWithObject 的字节码(有拆箱问题):
text
0: aload_1 // 将局部变量1(Long对象a的引用)压入栈
1: aload_2 // 将局部变量2(Long对象b的引用)压入栈
2: invokevirtual #2 // Method java/lang/Long.compareTo:(Ljava/lang/Long;)I
5: ireturn
分析: invokevirtual 是调用对象实例方法的指令。它需要操作数栈上先有对象的引用(aload 指令压入的)。只要执行 invokevirtual,对象就必须在堆里活着。
compareWithPrimitive 的字节码(无拆箱问题):
text
0: lload_0 // 将局部变量0(long基本类型a的值)压入栈
1: lload_2 // 将局部变量2(long基本类型b的值)压入栈
2: invokestatic #3 // Method java/lang/Long.compare:(JJ)I
5: ireturn
分析:
lload指令是专门加载long基本类型的,它从局部变量表直接把 8 字节的数字搬到操作数栈,根本没有对象引用的影子。invokestatic调用的是静态方法Long.compare,参数是(JJ)即两个long基本类型。全程在栈上搬运数字,根本没有访问堆内存。
终极总结
怎么知道放在堆里还是栈里?
- 看类型 :变量声明为
Long,数据本体在堆里,栈里存引用;变量声明为long,数据直接在栈里。 - 看指令 :底层用
aload+invokevirtual,就是在操作堆对象;底层用lload+invokestatic,就是在操作栈上的数字。
comparing 之所以慢,是因为它在排序循环中,不断逼迫 JVM 维持一堆 Long 对象在堆里存活,并反复通过引用去调用它们的方法。
comparingLong 之所以快,是因为它在提取的瞬间就把堆对象"抽干"了,只把抽出来的数字留在栈上,后续循环全是在栈上做纯数字运算,堆对象早早成了垃圾被清理或复用了。
拓展
怎么知道是放在堆内存里还是栈内存里了
- java字节码
- aload 加载对象
- lload 加载long类型值
- https://www.jianshu.com/p/d64a5dcccaa5