本文代码实践基于 JDK-21:
java 21.0.4 2024-07-16 LTS
这一篇让我们来简单梳理一下 Java 语言中的细枝末节。
57 将局部变量的作用域最小化
在 C 语言开发中,我们可能习惯于将变量定义在代码块的开头部分,但请注意,Java 语言不需要这样做。Java 代码中可以在任何出现代码语句的地方声明变量。所以,为了代码可读性,可维护性以及降低代码出错的风险,我们将局部变量定义在它第一次使用的地方便是最佳实践。如此,当想知道该变量是什么时,完全不需要滚动屏幕,分散精力去查找定义(当然,现在各种 IDE 都有实时预览功能)。
声明即定义,一个局部变量的声明应该包含一个初始化表达式。如果相关条件不够,那就延迟声明。对于循环来说,for 循环优于 while 循环,因为 for 循环中变量的作用域更小:
// for-each
for(Element e: c) {
// ...
}
// for
for(int i=0, n=e.size(); i<n; i++){ // 这里定义的变量 n 可以避免重复计算
// ...
}
// while
Iterator<Element> i = c.iterator();
while(i.hasNext()){
Element e = i.next();
// ...
}
可以看到,while 循环中变量 i 对于后续代码块都是可见的,而 for 循环中的变量则只作用于循环体。
最后一个减小变量作业域的杀手锏是——拆分,把大方法拆解成一个个小方法,每个方法只关注自己的任务,变量则按需定义,其作用范围自然也就小了。
58 for-each 循环优于传统的 for 循环
for-each 循环又被称为增强的 for 语句,我们通过代码示例来看看它和传统 for 循环的区别:
// 传统 for 循环迭代数组
for(int i=0, n=e.size(); i<n; i++){
// ...
}
// 传统 for 循环迭代集合
for(Iterator<Element> i = c.iterator(); i.hasNext();){
Element e = i.next();
// ...
}
// for-each
for(Element e: c) {
// ...
}
在上一条规则中,我们提到:for 循环优于 while 循环。但在几种不同的 for 循环写法中,for-each 更简短灵活(定义的变量少),也更安全(干扰少,注意力被分散的也少),并且没有性能惩罚问题。
class ForEachTest {
enum Face {
ONE, TWO, THREE, FOUR, FIVE, SIX;
}
// 错误示例:只会打印 ONE ONE ~ SIX SIX 6 种组合
@Test
void should_failed_print_two_faces_combination() {
Collection<Face> faces = EnumSet.allOf(Face.class);
for (Iterator<Face> it = faces.iterator(); it.hasNext(); ) {
// Face f1 = it.next();
for (Iterator<Face> it2 = faces.iterator(); it2.hasNext(); ) {
// Face f2 = it2.next();
// System.out.println(f1 + " " + f2);
System.out.println(it.next() + " " + it2.next());
}
}
}
// 正确示例:打印 36 种组合
@Test
void should_print_two_faces_combination() {
for (Face f1 : Face.values()) {
for (Face f2 : Face.values()) {
System.out.println(f1 + " " + f2);
}
}
}
}
for-each 循环不仅能遍历数组和集合,还能遍历实现了 Iterable 接口的对象。不过,它也有不适用的场景:
- 解析过滤:比如在遍历集合时,对特定的元素进行删除;
- 转换:比如在遍历数组或集合时,需要对特定的元素进行设置或修改;
- 平行迭代:比如并行遍历多个集合,需要根据索引变量等进行显示地控制,从而让多个迭代能同步前进。
59 了解和使用类库
理解这一条应该不难:不要重复造轮子。类库中的接口或函数都是多年来在各行各业千锤百炼过的,经得起时间的考验。使用类库,特别是标准类库以及行业内认可的第三方类库有如下优势:
1)可以充分利用这些编写标准类库的专家们的知识,以及前辈们的技术经验;
2)可以把更多的时间花在应用程序上,而不是底层的技术细节;
3)它们(类库)随着时间的推移,性能会不断提高,你要做的仅仅是升级版本;
4)在社区的推动下,新功能会不断被添加进来;
5)由于使用通用的类库,这样的代码在可读性,可维护性和可复用性上都有天然的优势,从而使我们自己的代码融入主流。
既然这么多优点,那程序员们选择标准类库实现就是理所当然吗?很遗憾,并不是,原因呢?也很遗憾,因为很多程序员并不知道标准类库提供了相关功能。所以,当有重要的新版本发布时,我们需要第一时间阅读 Release Notes,对相关新特性有一个大致了解。更多细节,我们可以阅读书籍或官方示例进行理解和实践。
不过,话又说回来,类库那么庞大,我们不可能深入去了解所有的细节,怎么办?抓重点,每个程序员至少应该对 java.lang
,java.util
,java.io
及其子包中的内容十分熟悉,其他类库则按需学习。
如今 AI 搜索已经变得十分强大,如果有一个需求,你不确定标准库是否有相关实现,搜索一下立马就能知道。如果标准库没有,那我们的第二选择就是那些高级的第三方类库,比如 Google 开源的 Guava 库。如果这些第三方库也没有呢?那就只能自己实现了。
P.S. 书中专门用随机数 Random 类作为反面案例,笔者觉得可以了解一下:
@Test
void should_get_random_number() {
int n = 2 * (Integer.MAX_VALUE / 3);
log.info("n: {}", n);
int low = 0;
for (int i = 0; i < 1000000; i++) {
// 使用 Random.nextInt() 方法,low 接近于 666666
// int r = random1(n);
// 使用 Random.nextInt(int n)方法,则 low 接近于 500000
// int r = random2(n);
// 使用 ThreadLocalRandom.nextInt()方法,则 low 接近于 500000
int r = random3(n);
if (r < n / 2) {
low++;
}
}
log.info("low: {}", low);
}
private int random1(int n) {
int number = random.nextInt();
return Math.abs(number) % n;
}
private int random2(int n) {
int number = random.nextInt(n);
return Math.abs(number) % n;
}
private int random3(int n) {
int number = ThreadLocalRandom.current().nextInt(n);
return Math.abs(number) % n;
}
优先使用 Random#nextInt(int n) ,而不是 Random#nextInt() (且 nextInt() 可能会输出 Integer.MIN_VALUE,从而导致上面的 Math.abs 函数溢出),到了 Java 7 之后,则优先使用 ThreadLocalRandom#nextInt(int n)。
60 如果需要精确的答案,请避免使用 float 和 double
之所以有这一条,是因为 float 和 double 并没有提供完全精确的结果,比如:
jshell
| 欢迎使用 JShell -- 版本 21.0.4
| 要大致了解该版本, 请键入: /help intro
jshell> System.out.println(1.03-0.42);
0.6100000000000001
jshell> System.out.println(1.03-0.62);
0.41000000000000003
jshell>
float 和 double 虽然是为科学计算和工程计算而设计,但也只是在广泛的数值范围上提供较为精确的快速近似计算。所以,货币计算绝不能使用 float 或 double。如果数不是很大,就直接使用 int 或 long,比如把货币中的值全部转成分而不是元,这样便可以使用 int 或 long 了。如果数值确实很大,已经超过 long 型的上限,则可以使用 BigDecimal,当然,这可能有一定的性能损耗:
jshell> BigDecimal d1 = new BigDecimal("1.03");
d1 ==> 1.03
jshell> BigDecimal d2 = new BigDecimal("0.42");
d2 ==> 0.42
jshell> BigDecimal d3 = d1.subtract(d2);
d3 ==> 0.61
jshell>
61 基本类型优于装箱基本类型
这一条大多数 Java 开发工程师应该都比较熟悉了,自动装箱和拆箱用起来很爽,它们之间的界限被编译器模糊了,但并没有被完全抹去,而这导致几个隐藏坑的出现:
1)二者有相同的值,但同一性不同:对装箱基本类型使用 ==
操作符几乎总是错误的。
Integer a = new Integer(42);
Integer b = new Integer(42);
Integer c = 321, d = 321;
log.info("a == b: {}", a == b); // false
log.info("c == d: {}", c == d); // false
// 错误的比较器
Comparator<Integer> wrongComparator = (a1, a2) -> (a1 < a2) ? -1 : (a1 == a2) ? 0 : 1;
log.info("a compare b: {}", wrongComparator.compare(a, b)); // 1
log.info("c compare d: {}", wrongComparator.compare(c, d)); // 1
1、如果将 a 和 b 使用如下方式定义,则结果会有所不同:
Integer a = 42, b = 42; log.info("a == b: {}", a == b); // true log.info("a compare b: {}", wrongComparator.compare(a, b)); // 0
这是因为 Integer 类默认对 -128 ~ 127 这个区间的对象进行了缓存。
2、如果用比较器描述一个类型的自然顺序,只要调用 Comparator#naturalOrder 方法即可,否则应该自己实现 Comparable 接口,而基本类型则更简单,直接使用对应的静态方法即可,比如 Integer#compare。
2)基本类型只有函数值,但装箱基本类型除此之外还有一个非函数值,即 null。
Integer a = null;
// java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "a" is null
if (a != 42) {
System.out.println("a != 42");
}
上面的代码不会打印任何内容,而是直接抛出空指针异常,这是因为在一项操作中混合基本类型和装箱基本类型,装箱基本类型会自动调用对应方法(比如 Integer#intValue )完成拆箱。
3)性能,基本类型在时间和空间上都要消耗更少的资源。
那有没有适合使用装箱基本类型的场景呢?主要有以下几种:
- 集合类,即作为集合中的元素,key 或 value
- 泛型类型参数
- 反射调用中,比如 Method#invoke
62 如果其他类型更适合,则尽量避免使用字符串
虽然字符串是最常用的类型,但也不能啥都往里装,其中一个很重要的原因是可读性,也就是无法表达出业务语义。以下场景就不建议使用字符串:
1)代替其他的值类型,比如网络传输过程中的数据,可能都是字符串,但接收到之后,就应该转成对应的类型,比如 Integer,Long 等;
2)枚举类型;
3)聚合类型:一个实体有多个域,用字符串表示就不合适,比如 className + "#" + i.next()
之类的方式;
4)能力表(capabilities):能力表指的是一组预定义的权限或功能标识,用于控制用户或系统对特定资源或操作的访问权限。如果用字符串表示这些能力,不仅缺乏语义,也容易出现拼写错误,缺乏可维护性和可扩展性。
63 了解字符串连接的性能
这一条说起来比较简单,针对多个字符串的合并操作,应该使用 StringBuilder 以取代 +
连接符,即:
// 正确的做法,
@Test
void should_join_string_via_string_builder() {
int n = 100000;
StringBuilder sb = new StringBuilder();
// StringBuilder sb = new StringBuilder(n); // 提前分配足够的空间
long startTime = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
sb.append("a");
}
String result = sb.toString();
long endTime = System.currentTimeMillis();
System.out.println("使用 StringBuilder 连接 " + n + " 个字符串耗时: " + (endTime - startTime) + " 毫秒");
}
// 不推荐的做法
@Test
void should_join_string_via_plus_operator() {
int n = 100000;
String result = "";
long startTime = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
result += "a";
}
long endTime = System.currentTimeMillis();
System.out.println("使用 '+' 操作符连接 " + n + " 个字符串耗时: " + (endTime - startTime) + " 毫秒");
}
像上面 10 万个字符串的连接,性能相差 100 倍,如果是 100 万个字符串的连接,则相差近 4000 倍(仅供参考),真的是天上一日,人间一年。之所以 +
有严重的性能损耗,是因为 String 对象是不可变的,每次连接操作都要创建一个新的 String 对象。假设第 1 个字符串的长度为 L1,以此类推,第 2,第 3,第 n 个字符串的长度为 L2,L3,Ln,那么:
- 第 1 次需要复制 L1 长度的字符;
- 第 2 次需要复制 L1 + L2 长度的字符;
- 第 3 次:L1 + L2 + L3
- ……
- 第 n 次:L1 + L2 + L3 + … + Ln
- 总复制长度:L1 + (L1 + L2) + (L1 + L2 + L3) + …+ (L1 + L2 + L3 + … + Ln) = n*L1 + (n-1)*L2 + (n-2)*L3 + … + Ln
如果我们把字符串的长度都置为 1,那么上面运算的结果就是 n*(n+1)/2,也就是 O(n^2) 的复杂度。不过,我们的讨论并没有到此结束,因为 Java 在不断的演进。
public class StringJoinTest {
private void testJoin() {
String prefix = "begin";
String suffix = "end";
String result = prefix + "#" + suffix;
System.out.println(result);
}
}
针对上面的代码示例,笔者分别用 Java 8,Java 11 和 Java 21 编译,然后查看他们的字节码:
1)Java 8
private void testJoin();
descriptor: ()V
flags: ACC_PRIVATE
Code:
stack=2, locals=4, args_size=1
0: ldc #2 // String begin
2: astore_1
3: ldc #3 // String end
5: astore_2
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_1
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: ldc #7 // String #
19: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: aload_2
23: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
26: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
29: astore_3
30: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
33: aload_3
34: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
37: return
LineNumberTable:
line 4: 0
line 5: 3
line 6: 6
line 7: 30
line 8: 37
把上面的字节码翻译成源码:
private void testJoin() {
// 定义两个字符串
String str1 = "begin";
String str2 = "end";
// 使用 StringBuilder 拼接字符串
String result = new StringBuilder()
.append(str1).append("#").append(str2)
.toString();
// 打印拼接结果
System.out.println(result);
}
2)Java 11
private void testJoin();
descriptor: ()V
flags: (0x0002) ACC_PRIVATE
Code:
stack=2, locals=4, args_size=1
0: ldc #2 // String begin
2: astore_1
3: ldc #3 // String end
5: astore_2
6: aload_1
7: aload_2
8: invokedynamic #4, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
13: astore_3
14: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
17: aload_3
18: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
LineNumberTable:
line 4: 0
line 5: 3
line 6: 6
line 7: 14
line 8: 21
把上面的字节码翻译成源码(由于使用了 invokedynamic 指令,这里只是给出类比的源码):
private void testJoin() {
// 定义两个字符串
String str1 = "begin";
String str2 = "end";
// 使用字符串模板拼接字符串
String result = str1 + "#" + str2;
// 打印拼接结果
System.out.println(result);
}
3)Java 21
private void testJoin();
descriptor: ()V
flags: (0x0002) ACC_PRIVATE
Code:
stack=2, locals=4, args_size=1
0: ldc #7 // String begin
2: astore_1
3: ldc #9 // String end
5: astore_2
6: aload_1
7: aload_2
8: invokedynamic #11, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
13: astore_3
14: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream;
17: aload_3
18: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
LineNumberTable:
line 4: 0
line 5: 3
line 6: 6
line 7: 14
line 8: 21
仅仅就 testJoin 方法而言,Java 21 产生的字节码与 Java 11 没有太大区别。
比较两类字节码,我们可以看到它们是完全不同的实现:
- Java 8 直接生成 StringBuilder 对象,这也在告诉我们,针对这种非循环的简单场景,可以直接使用
+
进行拼接,毕竟可读性更友好; - Java 11/21 则使用了更加现代的优化方式,动态绑定方法调用,从而省去了创建对象等相关成本。
针对 invokedynamic 指令及这里的 StringConcatFactory.makeConcatWithConstants 引导方法(Bootstrap Method),笔者将在后续文章中进行总结。
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
// @BenchmarkMode(Mode.AverageTime)
// @OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringConcatBenchmark {
private static final String prefix = "prefix";
private static final int stringLength = 10; // 1000
private static final String[] strings = new String[stringLength];
static {
for (int i = 0; i < stringLength; i++) {
strings[i] = "str" + i;
}
}
@Benchmark
public void testSingleStringBuilder(Blackhole bh) {
String result = new StringBuilder(prefix).append(",").append(strings[0]).toString();
bh.consume(result);
}
@Benchmark
public void testSingleJdk11Style(Blackhole bh) {
String result = prefix + "," + strings[0];
bh.consume(result);
}
@Benchmark
public void testSingleJdk8Style(Blackhole bh) {
String result = new StringBuilder().append(prefix).append(",").append(strings[0]).toString();
bh.consume(result);
}
@Benchmark
public void testDoubleJdk11Style(Blackhole bh) {
double d = 1.01;
String result = prefix + "," + strings[0] + d;
bh.consume(result);
}
@Benchmark
public void testDoubleJdk8Style(Blackhole bh) {
double d = 1.01;
String result = new StringBuilder().append(prefix).append(",").append(strings[0]).append(d).toString();
bh.consume(result);
}
@Benchmark
public void testStringBuilder(Blackhole bh) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < stringLength; i++) {
builder.append(strings[i]);
}
bh.consume(builder.toString());
}
@Benchmark
public void testJdk11Style(Blackhole bh) {
String s = "";
for (int i = 0; i < stringLength; i++) {
s = s + strings[i];
}
bh.consume(s);
}
@Benchmark
public void testJdk8Style(Blackhole bh) {
String s = "";
for (int i = 0; i < stringLength; i++) {
s = new StringBuilder().append(s).append(strings[i]).toString();
}
bh.consume(s);
}
}
上面是一个字符串拼接的代码示例,笔者在 Java 8 和 Java 21 上分别进行了测试(数据仅供参考):
1)单次连接
Java 8 吞吐量 | Java 21 吞吐量 | Java 8 平均时间 | Java 21 平均时间 | |
---|---|---|---|---|
testSingleJdk11Style | 107170915.338 ± 430114.982 op/s | 100521687.403 ± 3912737.726 op/s | 8.960 ± 0.098 ns/op | 9.342 ± 0.221 ns/op |
testSingleJdk8Style | 105837088.949 ± 1263340.316 op/s | 121281278.456 ± 3243237.103 op/s | 9.208 ± 0.199 ns/op | 7.918 ± 0.124 ns/op |
testSingleStringBuilder | 104997963.858 ± 396729.701 op/s | 122274204.358 ± 3305232.034 op/s | 9.483 ± 0.296 ns/op | 7.887 ± 0.101 ns/op |
从数据上看,单次连接的性能都非常高,Java 21 上的性能稍好,但差别小到可以忽略。
2)带双精度值时的单次连接
Java 8 吞吐量 | Java 21 吞吐量 | Java 8 平均时间 | Java 21 平均时间 | |
---|---|---|---|---|
testDoubleJdk11Style | 20716094.888 ± 1246028.691 op/s | 35926116.409 ± 402895.101 ops/s | 44.354 ± 0.379 ns/op | 27.975 ± 0.542 ns/op |
testDoubleJdk8Style | 20168090.504 ± 1057343.312 ops/s | 32094403.788 ± 531925.283 ops/s | 45.943 ± 0.491 ns/op | 29.266 ± 1.558 ns/op |
当我们引入一个 double 类型后,单次连接的性能下滑到了原来的 1/5 左右,并且 Java 21 比 Java 8 性能更好,约 1.7 倍,这也是为什么我们总是建议使用较新 JDK 版本的原因。
3)多次连接
10 个字符串:
Java 8 吞吐量 | Java 21 吞吐量 | Java 8 平均时间 | Java 21 平均时间 | |
---|---|---|---|---|
testJdk11Style | 10390426.971 ± 203934.440 ops/s | 13044587.021 ± 535052.447 ops/s | 96.813 ± 1.431 ns/op | 76.822 ± 2.118 ns/op |
testJdk8Style | 10494425.452 ± 240709.562 ops/s | 29821496.221 ± 149495.366 ops/s | 96.381 ± 1.156 ns/op | 34.546 ± 0.362 ns/op |
testStringBuilder | 18611243.332 ± 220296.276 ops/s | 29700616.543 ± 453509.751 | 54.282 ± 0.536 ns/op | 34.513 ± 0.350 ns/op |
1000 个字符串:
Java 8 吞吐量 | Java 21 吞吐量 | Java 8 平均时间 | Java 21 平均时间 | |
---|---|---|---|---|
testJdk11Style | 5369.574 ± 65.200 ops/s | 9969.800 ± 69.834 ops/s | 179070.869 ± 4395.963 ns/op | 103666.226 ± 3256.089 ns/op |
testJdk8Style | 5339.148 ± 54.853 ops/s | 10822.601 ± 231.687 ops/s | 176749.392 ± 3925.180 ns/op | 94050.111 ± 1417.218 ns/op |
testStringBuilder | 199659.774 ± 4130.638 ops/s | 190089.456 ± 1611.015 ops/s | 4839.237 ± 97.958 ns/op | 5232.958 ± 45.048 ns/op |
当使用循环后,性能开始以百倍/千倍的速度急剧下滑,但使用 StringBuilder 模式的性能明显高于 +
模式,特别是当字符串的数量不断提升的时候。由此,我们可以简单总结下:
- 单次连接的性能非常好,编译器会优化,可以不使用 StringBuilder 模式;
- 但对于多次连接,务必使用 StringBuilder 模式;
- 一般来说,JDK 版本越新,性能的提升可能越好。
范学雷老师在专栏《深入剖析 Java 新特性》中曾说:
给你一个保守、粗暴的估计,你如果从 JDK 8 迁移到 JDK 17,并且能够恰当使用 JDK 8 以后的新特性的话,产品的代码量可以减少 20%,代码错误可以减少 20%,产品性能可以提高 20%,维护成本可以降低 20%。这些,都是实实在在的收益。
64 通过接口引用对象
这一条比较简单,在前面的相关总结中也有提及,即对于参数、返回值、变量或域来说,声明时应该尽量使用对应的接口类型,比如:
// 推荐:使用 List 接口声明
List<String> list = new LinkedList<>();
// 不推荐:使用具体实现类声明
LinkedList<String> list = new LinkedList<>();
这样做的好处是灵活性,当我想换一个实现类,周围的代码逻辑并不会受到影响,毕竟使用方也不知道内部用了哪种实现。你可能会问,为什么要改变实现?也许是因为改善性能,也许是因为新的实现类有了某些之前缺乏的新特性等等。那如果没有合适的接口类型呢?可以用类层次结构中提供了必要功能的最小的具体类来引用对象。
65 接口优先于反射机制
先看一个书中的代码示例:
void fatalError(String errMsg) {
System.err.println("Fatal Error: " + errMsg);
System.exit(1);
}
void reflectiveInstantiation(String[] args) {
// 获取 Class 对象
Class<? extends Set<String>> clazz = null;
try {
clazz = (Class<? extends Set<String>>) Class.forName(args[0]);
} catch (ClassNotFoundException e) {
fatalError("class not found: " + args[0]);
}
// 获取构造器
Constructor<? extends Set<String>> cons = null;
try {
cons = clazz.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
fatalError("constructor not found: " + clazz);
}
// 创建对象
Set<String> set = null;
try {
set = cons.newInstance();
} catch (InvocationTargetException e) {
fatalError("constructor invocation failed: " + clazz);
} catch (InstantiationException e) {
fatalError("instantiation exception: " + clazz);
} catch (IllegalAccessException e) {
fatalError("constructor is not accessible: " + clazz);
} catch (IllegalArgumentException e) {
fatalError("invalid arguments: " + clazz);
}
// 添加元素
List<String> argsList = Arrays.asList(args);
List<String> argsList2 = argsList.subList(1, argsList.size());
set.addAll(argsList2);
System.out.println("args: " + set);
}
这是一个通用的反射代码示例,大多数时候我们都是这样编写反射代码的,这类代码有两个显而易见的问题:
1)长达 20 多行的冗余样本代码,但如果直接使用 Set<String> set = new HashSet<>()
则只需要一行代码;
2)多个异常捕获(可以通过 ReflectiveOperationException
合并部分异常,这是 Java 7 引入的一个父类)。
不过,当类的对象实例创建出来以后,如果我们已经知道其类型(比如这里的接口类型 Set<String>),那么这个通过反射创建的对象实例与普通构造器创建的对象实例也就没有什么区别了,后续的使用是一样的。也就是说,我们可以通过有限的方式使用反射机制,比如只创建对象实例,然后通过接口等形式来引用这个对象实例,继而通过正常的方式使用,而不是 Method#invoke
。这也是这一条想要表达的主要观点。
反射提供了通过程序访问任意类的能力,这显然有其优势。比如依赖注入框架或是某些类在编译时还不存在的场景,用反射就非常合适,特别是已知其接口或(抽象)父类类型的时候。但反射的使用也要付出一定的代价,这就是为什么要在有限方式下使用:
- 损失了编译期类型检查的优势(上面的标准代码需要捕获那么多异常);
- 笨拙冗长的反射样本代码;
- 性能损失。
66 谨慎地使用本地方法
本地方法(native method)一般是指通过本地语言(如 C 或 C++)编写的代码,Java 通过 JNI(Java Native Interface)机制对其进行跨语言访问:
public class Object {
// ...
@IntrinsicCandidate
public final native Class<?> getClass();
@IntrinsicCandidate
public native int hashCode();
// ...
}
为何需要调用本地方法?一般有下面几个场景:
- 性能,比如通过 C 甚至是汇编语言来提升关键代码的性能;
- 某个体系架构或操作系统特有的功能,即“访问特定于平台的机制”的能力;
- 处理本地遗留代码库的能力,从而帮助实现新老系统的切换;
- ……
显然,如果使用本地方法,那么这一块的逻辑就不再是平台无关,即牺牲了代码的可移植性。不过,这并不是最重要的,也许使用本地方法本身就是一个伪需求。一方面,随着 Java 版本的不断迭代,很多宿主平台上特有的功能,被不断添加到标准库或第三方库中。另一方面,对于大多数任务,现代版本的 JVM 能提供与 C/C++ 相当的性能。也就是说,不是特别的需求,一般是用不到本地方法的。所以,使用之前务必三思,毕竟维护一套特殊的代码不是一件容易的事:
- 本地方法使用低级语言开发,安全性大大降低,比如内存容易崩(大神写的代码除外);
- 无可移植性;
- 难以调试;
- 垃圾回收不会监控这一块内存,所以不容易知道这块代码的问题;
- Java --> C --> Java,跨语言的出入口都有额外的开销;
- 需要“胶水代码”的本地方法编写起来单调乏味,且难以阅读;
- ……
以下是一个本地方法的代码示例,方便大家对 JNI 机制有一个直观的认识(此处使用 Java 17 运行):
package jvm.tech.demonlee.jni;
public class FooNative {
public native int bar(int i, String s);
public static void main(String[] args) {
FooNative fooNative = new FooNative();
int result = fooNative.bar(6, "世界");
System.out.println("...........result: " + result);
}
}
生成头文件:
javac -h . jvm/tech/demonlee/jni/FooNative.java
jvm_tech_demonlee_jni_FooNative.h 头文件内容如下:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class jvm_tech_demonlee_jni_FooNative */
#ifndef _Included_jvm_tech_demonlee_jni_FooNative
#define _Included_jvm_tech_demonlee_jni_FooNative
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: jvm_tech_demonlee_jni_FooNative
* Method: bar
* Signature: (ILjava/lang/String;)I
*/
JNIEXPORT jint JNICALL Java_jvm_tech_demonlee_jni_FooNative_bar
(JNIEnv *, jobject, jint, jstring);
#ifdef __cplusplus
}
#endif
#endif
编写 foo_native.c
:
#include <stdio.h>
#include "jvm_tech_demonlee_jni_FooNative.h"
JNIEXPORT jint JNICALL Java_jvm_tech_demonlee_jni_FooNative_bar
(JNIEnv *env, jobject thisObject, jint i, jstring str) {
// 将 jstring 转换为 C 的 char*
const char *nativeString = (*env)->GetStringUTFChars(env, str, NULL);
printf("+++++++++Hello, %s, %d\n", nativeString, i);
(*env)->ReleaseStringUTFChars(env, str, nativeString);
jint result = 10*i*i;
return result;
}
使用 gcc 编译,生成 libfoo_native.dylib
库:
# macOS 下命令
gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo_native.dylib -shared foo_native.c
运行:
> java -Djava.library.path=./ jvm.tech.demonlee.jni.FooNative
+++++++++Hello, 世界, 6
...........result: 360
67 谨慎地进行优化
要写好的程序而不是快的程序,因为过早地优化往往弊大于利,除非一开始就明确提出了特别的性能要求。
不要去计较效率上一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。
规则一:不要进行优化;
规则二(仅针对专家):还是不要进行优化。
那么,什么样的代码是好的代码?拥有良好的设计,高内聚,低耦合。这么说可能比较抽象,或者说空洞。其实意思很简单,就是类,包,模块等做到了真正的封装,即信息隐藏。一旦封装到位,即便当前程序的性能不够,我们也可以慢慢优化,因为封装良好的类/包/模块的调整只会影响自己,并不会干扰边界外的类/包/模块。
也就是说,即使程序不够快,但其良好的架构能让它得到进化和修正。所以,不要为了性能而牺牲合理的架构。当一个系统设计完成之后,我们需要重点关注那些一旦确定就难以更改的部分:模块之间,模块与外界之间交互的组件。在这些组件的设计中,最主要的是 API,通信协议以及数据格式的定义,如果设计不当,可能会对系统的性能起到相当大的限制作用。
如果确定需要进行优化,第一件事就是性能测量,在优化前后都需要测量(每一轮优化)。所以,性能剖析工具不可或缺。另外一个值得注意的框架是 JMH,它提供了 Java 代码性能详情的能力。通过性能剖析工具,找到相关问题的原因后,便可以按需优化,但需要重点关注代码中选择的算法实现(如果有的话),因为优化再多的细枝末节也弥补不了算法的选择失当。
68 遵守普遍接受的命名惯例
Java 平台有一套完整的命名规范,即 The Java Language Specification: Names,不严格地讲,命名惯例主要分为字面和语法两类。其中字面惯例明确直接,而语法惯例则更复杂,更灵活(松散),争议也更大,笔者简单总结如下:
标识符类型 | 字面 | 语法 | 示例 |
---|---|---|---|
包或者模块 | 层次状,顶级域名在前,用 . 分隔,每个部分都是小写字母(少数情况下可能有数字);包名称的其余部分应该比较简短,一般不超过 8 个字符。 |
无明确规定 | org.junit.jupiter.api, com.google.common.collect |
类或者接口 | 一个或多个单词,单词首字母大写,避免用缩写(通用除外,比如 Max,Min 等) | 可被实例化的类通常用名词或名词短语;不可实例化的工具类通常用复数名词;接口与类相似,有时用一个以 able 或 ible 结尾的形容词来命名;注解类型有很多用处,所以不限名词,动词,介词或形容词 | Stream, FutureTask, HttpClient, LinkedHashMap, Collectors, Runnable, Inject, BindingAnnotation |
方法 | 与类和接口的名称一样,但第一个字母小写 | 执行某个动作的方法一般用动词或动词短语;对于返回 boolean 类型值的方法一般使用 is 开头,很少用 has,而非 boolean 返回类型值则可以用名词,名词短语或 get 开头的动词短语;转换类型 toXxx,返回视图 asXxx,基本包装类型到基本类型值 xxxValue | remove, groupingBy, getTime, isEmpty, size, hashCode, toString, asList, intValue |
常量域 | 一个或多个大写单词,单词之间用 _ 分隔 |
名词或名词短语 | MIN_VALUE, NEGATIVE_INFINITY |
普通域或局部变量 | 与类和接口的名称一致,但第一个字母小写;局部变量则允许缩写;方法的输入参数是一种特殊局部变量,其名称需要反映业务上下文,命名需更慎重 | 名词或名词短语,如果是 boolean 类型则会省略前面的 is;局部变量类似,但语法规则更弱 | height, bodyStyle, initialized, i, houseNum |
类型参数 | 单个字母,T 类型,E 集合的元素类型,K 和 V 则表示 map 的健和值类型,X 异常,R 函数的返回类型 | T, E, K, V, X, R, U, V, T1, T2 |
好,本期内容就总结到这里,下期再见。