1. 为什么Long的源码值得单独拎出来读
很多人读JDK源码,第一反应是去啃HashMap、ConcurrentHashMap、AQS这些“大件”,觉得Long这种包装类太简单,扫一眼API文档就够了。我一开始也这么想,直到有一次在排查一个线上数据对不上的问题时,才发现自己对Long的理解漏洞百出——缓存边界、拆箱时机、equals和==的差异、parseLong和valueOf的底层行为,这些细节在真实项目里全都会咬人。
Long这个类看起来只是把long包了一层,但它身上浓缩了Java包装类设计的几乎所有典型问题:缓存机制、自动装箱拆箱、进制转换、位运算、溢出处理、字符串解析。把Long读透,等于把一整套包装类的设计思路摸清楚了,后面再看Integer、Short、Byte基本就是举一反三。
这篇文章面向的是已经会写Java、但没系统读过Long源码的开发者。我会从字段结构一路拆到parseLong的每一位运算,把“为什么这么设计”讲清楚,同时穿插我自己踩过的坑。读完之后,你至少能回答这几个问题:Long.valueOf(127)和Long.valueOf(128)到底差在哪、Long的缓存能不能调、parseLong为什么不用Integer.parseInt那套写法、Long的hashCode为什么那么算。
2. Long的骨架:字段、常量与继承关系
2.1 类声明里藏着的两个信息
Long的类声明是这样的:
public final class Long extends Number implements Comparable<Long>这里有两个点值得说。第一,final修饰,意味着它不能被继承,这是所有包装类的共同选择——包装类的语义必须稳定,不允许子类改写equals、hashCode这些基础行为。第二,它继承了Number并实现了Comparable,前者提供了intValue()、longValue()、doubleValue()这一组拆箱方法,后者提供了自然排序能力,这也是它能直接丢进TreeMap、Collections.sort的前提。
Number是个抽象类,里面定义了六个xxxValue()抽象方法。Long对它们的实现基本都是强转或截断,比如intValue()就是(int)value,doubleValue()就是(double)value。这里有个容易忽略的点:从Long转int是可能丢精度的,因为long是64位,int只有32位,高位会被直接砍掉。我在一个统计项目里就遇到过,把Long类型的累计值直接intValue()之后变成了负数,排查了半天才发现是溢出截断。
2.2 那个被很多人忽略的value字段
Long内部只有一个实例字段:
private final long value;final保证不可变,这是包装类能安全用作HashMap的key、能放心共享的前提。所有对Long的操作,本质上都是围绕这个value做运算,然后返回一个新的Long或者一个基本类型。
这里要提醒一句:不可变意味着每次运算都会产生新对象。如果你在一个循环里频繁做Long的加减,会产生大量临时对象。我在一个批量对账的场景里,原本用Long做累加,十万条数据跑下来GC压力明显,后来改成基本类型long累加、最后再装箱,性能提升非常直观。这不是说Long不能用,而是要知道它的代价在哪。
2.3 常量与缓存边界
Long里定义了几个关键常量:
public static final long MIN_VALUE = 0x8000000000000000L; public static final long MAX_VALUE = 0x7fffffffffffffffL; public static final int SIZE = 64; public static final int BYTES = SIZE / Byte.SIZE;MIN_VALUE是-2^63,MAX_VALUE是2^63 - 1,这两个值在溢出判断里会反复用到。SIZE是64,表示位数;BYTES是8,表示字节数。这两个常量在做序列化、内存估算的时候很有用。
真正关键的是缓存相关的部分:
private static class LongCache { private LongCache(){} static final Long cache[] = new Long[-(-128) + 127 + 1]; static { for(int i = 0; i < cache.length; i++) cache[i] = new Long(i - 128); } }这段代码的意思是:Long在类加载时会预先创建-128到127这256个Long对象,放在一个静态数组里。当你调用Long.valueOf()且值落在这个区间时,直接返回缓存对象,不会新建。
注意:这个缓存区间是固定的,不能像
Integer那样通过-XX:AutoBoxCacheMax调整。Long的缓存上限写死在代码里,就是127。这一点很多人会搞混,以为Long也能调缓存范围。
为什么只缓存这个区间?因为JDK设计者认为这个范围的值在大多数程序里出现频率最高,缓存它们能显著减少对象创建。而更大的值如果也缓存,会占用大量内存,得不偿失。这个取舍思路在所有包装类里是一致的。
3. valueOf与自动装箱:缓存到底在什么时候生效
3.1 valueOf的完整逻辑
Long.valueOf(long)的实现非常短:
public static Long valueOf(long l) { final int offset = 128; if (l >= -128 && l <= 127) { return LongCache.cache[(int)l + offset]; } return new Long(l); }逻辑很直白:落在[-128, 127]就查缓存,否则新建。这里的offset = 128是为了把-128映射到数组下标0,127映射到下标255。
还有一个重载版本valueOf(String s),它内部调用parseLong(s, 10),然后再走valueOf(long)。也就是说,从字符串创建Long同样会走缓存。这一点在写代码时值得留意:Long.valueOf("100")和Long.valueOf("1000"),前者返回的是缓存对象,后者是新对象。
3.2 自动装箱背后调用的就是valueOf
当你写Long a = 100L;的时候,编译器会自动插入Long.valueOf(100L)。所以自动装箱是否命中缓存,完全取决于值是否在[-128, 127]。
这就引出了那个经典面试题:
Long a = 127L; Long b = 127L; System.out.println(a == b); // true Long c = 128L; Long d = 128L; System.out.println(c == d); // falsea == b为true是因为两者都指向缓存里的同一个对象;c == d为false是因为128超出了缓存范围,各自new了一个。这个坑我在实际项目里见过不止一次,有人用==比较两个Long的ID,小数据量测试时全对,一上生产数据量大了就出问题。
实操建议:比较两个
Long的值,永远用equals或者先longValue()再比。不要用==,除非你非常确定它们来自缓存且值在范围内。
3.3 缓存带来的一个隐蔽问题
缓存对象是共享的,虽然Long不可变所以共享本身安全,但如果你用Long作为锁对象,就可能出问题。比如:
Long lock = 100L; synchronized (lock) { ... }如果另一个地方也用Long lock2 = 100L;,它们其实是同一个对象,会互相阻塞。这种问题极其隐蔽,因为看起来是两个独立的变量。我的建议是:永远不要用包装类做锁对象,用专门的Object实例或者ReentrantLock。
4. parseLong:一个字符一个字符啃出来的解析逻辑
4.1 为什么不用Integer.parseInt那套
Long.parseLong(String)最终会调用parseLong(String s, int radix)。这个方法的核心是一个循环,逐字符解析。很多人会问:为什么不直接复用Integer的解析逻辑?答案是范围不同。int是32位,long是64位,溢出判断的边界完全不一样,而且Long要处理更大的数值,不能简单套用。
4.2 解析过程拆解
方法开头先做了一堆边界检查:字符串非空、radix在[2, 36]之间、长度大于0。然后处理正负号,确定negative标志和第一个有效字符的位置。
接下来是核心循环,我把它简化一下:
long result = 0; long limit = negative ? Long.MIN_VALUE : -Long.MAX_VALUE; long multmin = limit / radix; while (i < len) { int digit = Character.digit(s.charAt(i++), radix); if (digit < 0) throw new NumberFormatException(...); if (result < multmin) throw new NumberFormatException(...); result *= radix; if (result < limit + digit) throw new NumberFormatException(...); result -= digit; }这里有个非常巧妙的设计:整个累加过程用的是负数。为什么?因为Long.MIN_VALUE的绝对值比Long.MAX_VALUE大1,如果用正数累加,处理Long.MIN_VALUE时会溢出。用负数累加,Long.MIN_VALUE就能被正确表示。这个技巧在Integer、Short里也是一样的。
溢出判断分两步:先判断result < multmin,确保乘radix之后不会越界;再判断result < limit + digit,确保减去当前位之后不会越界。这两步缺一不可,顺序也不能反。
4.3 我踩过的一个进制坑
parseLong支持2到36进制。有一次我处理一批带前缀的编号,想当然地用了Long.parseLong("0x1F", 16),结果直接抛异常。原因是parseLong不识别0x前缀,它只认纯数字字符。要处理带前缀的字符串,得用Long.decode("0x1F"),它会自动识别0x、0X、#、0这些前缀。
Long.parseLong("1F", 16); // 31,正确 Long.parseLong("0x1F", 16); // 抛NumberFormatException Long.decode("0x1F"); // 31,正确这个区别在解析配置文件、处理十六进制ID的时候特别容易踩。
4.4 性能上的一个细节
parseLong内部用的是Character.digit(char, int),这个方法会处理各种Unicode数字字符,比直接c - '0'要慢一些。如果你在极端性能敏感的场景下解析大量十进制数字,自己写一个只处理ASCII数字的解析器会更快。但绝大多数业务场景下,parseLong的性能完全够用,没必要过早优化。
5. 位运算与进制转换:Long身上的工具方法
5.1 位运算方法族
Long提供了一组静态位运算方法,这些方法在写底层代码、做标志位管理时非常有用:
| 方法 | 作用 | 典型场景 |
|---|---|---|
highestOneBit(long) | 返回最高位1对应的值 | 找小于等于n的最大2的幂 |
lowestOneBit(long) | 返回最低位1对应的值 | 位图、权限判断 |
bitCount(long) | 统计1的个数 | 汉明重量计算 |
numberOfLeadingZeros(long) | 前导零个数 | 二分查找、位图定位 |
numberOfTrailingZeros(long) | 末尾零个数 | 找最低位1的位置 |
reverse(long) | 位反转 | 某些加密、哈希算法 |
rotateLeft/rotateRight | 循环移位 | 哈希、加密 |
这些方法的实现都是纯位运算,没有循环,效率极高。比如highestOneBit用的是连续右移再或的经典技巧:
public static long highestOneBit(long i) { i |= (i >> 1); i |= (i >> 2); i |= (i >> 4); i |= (i >> 8); i |= (i >> 16); i |= (i >> 32); return i - (i >>> 1); }先把最高位1右边全部填成1,再减去右移一位的结果,就只剩下最高位那个1。这个技巧值得记住,面试和实际优化都用得上。
5.2 进制转换:toHexString、toOctalString、toBinaryString
这三个方法把long转成对应进制的字符串。它们的实现思路类似,都是通过位掩码和移位逐段处理。以toHexString为例,它每次取低4位,查一个预定义的字符数组,然后右移4位,直到值为0。
这里有个细节:它们处理的是无符号表示。也就是说,Long.toHexString(-1L)返回的是"ffffffffffffffff",而不是带负号的字符串。如果你需要带符号的十六进制表示,得自己处理。
Long.toHexString(-1L); // "ffffffffffffffff" Long.toBinaryString(-1L); // 64个1 Long.toOctalString(-1L); // "1777777777777777777777"我在做协议解析的时候,经常用toHexString把long转成十六进制看字节分布,比手动算方便得多。但要注意它不会补前导零,如果需要固定长度,得自己String.format("%016x", value)。
5.3 toString的实现与缓存
Long.toString(long)内部调用toString(long, 10)。这个方法有个小优化:对于小数字(绝对值小于某个阈值),会走一个更快的路径,直接用查表法生成字符串。大数字才走通用的除法循环。
另外,Long里还缓存了toString的结果吗?答案是没有。Integer有一个IntegerCache用于valueOf,但toString的结果并不缓存。每次调用toString都会新建字符串。所以在高频日志场景下,如果同一个Long要反复转字符串,可以考虑自己缓存。
6. equals、hashCode与compare:比较逻辑的正确姿势
6.1 equals的实现
public boolean equals(Object obj) { if (obj instanceof Long) { return value == ((Long)obj).longValue(); } return false; }逻辑很简单:先判断类型,再比较值。注意它用的是instanceof而不是getClass(),这意味着如果有一个Long的子类(虽然Long是final的,不可能有子类),也会被认作相等。这是JDK里equals的常见写法。
6.2 hashCode为什么那么算
public int hashCode() { return Long.hashCode(value); } public static int hashCode(long value) { return (int)(value ^ (value >>> 32)); }这个算法把64位的值的高32位和低32位异或,然后截断成32位。为什么这么做?因为HashMap的桶下标是基于hashCode的,如果直接用低32位,高位信息就丢了,容易产生碰撞。异或一下,高位信息就混进来了,分布更均匀。
这个技巧在Long、Double、UUID等64位值的hashCode里都能看到。理解了这个,你就明白为什么自己写hashCode时也要把高位混进来。
6.3 compare与compareUnsigned
Long.compare(long x, long y)的实现是:
public static int compare(long x, long y) { return (x < y) ? -1 : ((x == y) ? 0 : 1); }注意它没有用x - y,因为减法可能溢出。比如x = Long.MAX_VALUE,y = -1,x - y会溢出成负数,导致比较结果错误。用比较运算符虽然多几次判断,但绝对安全。
compareUnsigned则是把两个long当作无符号数比较。它的实现是:
public static int compareUnsigned(long x, long y) { return compare(x + Long.MIN_VALUE, y + Long.MIN_VALUE); }加上MIN_VALUE相当于把有符号数的范围平移到无符号数的顺序上,这个技巧很巧妙。在处理哈希值、ID这类不关心符号的场景下,compareUnsigned比compare更合适。
实操提醒:
Long的compareTo调用的是compare,所以TreeSet<Long>、TreeMap<Long, ?>默认用的是有符号比较。如果你放进去的是哈希值这种可能为负的数,排序结果可能不符合预期,需要自己传Comparator。
7. 那些年我在Long上踩过的坑
7.1 拆箱空指针
这是最经典的坑:
Long value = null; long v = value; // NullPointerException自动拆箱时如果对象为null,直接NPE。这个坑在三目运算符里尤其隐蔽:
Long a = null; Long b = 10L; long result = true ? a : b; // NPE因为三目运算符的类型对齐规则,a和b会被拆箱成long再运算,a为null就炸了。我见过一个项目里,这种写法在测试环境因为数据恰好不为空一直没暴露,上线后遇到空值直接崩。
防御方式:三目运算符里如果两个分支类型不一致,显式保证不出现
null,或者用Optional、Objects.requireNonNullElse这类工具。
7.2 Long和long混用导致的比较错误
Long a = 1000L; long b = 1000L; System.out.println(a == b); // true这里a == b为true,因为a会自动拆箱成long再和b比较。但如果是:
Long a = 1000L; Long b = 1000L; System.out.println(a == b); // false两个Long比较就是引用比较,1000超出缓存范围,所以是false。一个操作数是基本类型时走值比较,两个都是包装类型时走引用比较,这个规则一定要记牢。
7.3 溢出无声无息
long的加减乘都可能溢出,而且不会抛异常:
long max = Long.MAX_VALUE; long result = max + 1; // 变成 Long.MIN_VALUE在做金额、计数这类场景时,溢出会导致数据完全错误。JDK 8之后可以用Math.addExact、Math.multiplyExact,溢出时抛ArithmeticException:
try { long result = Math.addExact(max, 1); } catch (ArithmeticException e) { // 处理溢出 }我在对账系统里就把所有关键的累加换成了addExact,虽然有一点性能开销,但能第一时间发现异常,比事后排查数据对不上要划算得多。
7.4 序列化时的兼容性
Long实现了Serializable,序列化时写的是long值。但如果你把Long和long混在同一个字段上(比如版本升级时改了类型),反序列化可能出问题。更稳妥的做法是保持类型稳定,或者用自定义的序列化策略。
8. 从Long看包装类的设计套路
把Long读完,你会发现JDK包装类的设计有一套固定套路:不可变字段 + 缓存 + 静态工厂 + 位运算工具 + 严格的解析逻辑。这套套路在Integer、Short、Byte、Character上几乎一模一样,只是缓存范围和位宽不同。
理解这套套路的价值在于,当你需要自己写一个值对象时,可以借鉴这些设计:用final字段保证不可变、用静态工厂控制实例创建、用缓存优化高频值、用位运算提升性能、用严格的边界检查防止溢出。这些不是Long独有的技巧,而是JDK沉淀下来的通用工程经验。
我个人在读完Long之后,最大的收获不是记住了某个API,而是养成了一个习惯:看到包装类就想到缓存边界,看到比较就想到拆箱,看到累加就想到溢出。这三个条件反射,帮我避开了不少生产事故。如果你还没系统读过Long,建议从valueOf和parseLong这两个方法入手,把每一行都推演一遍,收获会比泛读十个类还大。