Spring Native 0.11发布,带来新的AOT引擎和性能优化

Spring Native 0.11已于2021年12月9日发布。

这个宏大的版本是Spring团队五个月辛勤工作的结果,他们一直在研究一个全新的架构,将让Spring使用GraalVM创建原生可执行文件的方式提升到一个新的水平。你目前已经可以已经在start.spring.io上试用了!

想了解有关Spring Native 0.11的更多信息,可以查看来自Spring布道师的新一期的Spring Tips视频(在YouTube上)。

新的AOT引擎

这个版本最大的变化无疑是引入了新的AOT引擎,该引擎在构建时对Spring程序进行深入的转化和分析,并生成所需的GraalVM Native配置。这些转换由Maven和Gradle Spring AOT插件执行。

spring boot native

更深入地说,AOT引擎在构建时评估构建环境,以便生成专门为您的应用程序优化后的 application context 和 Spring factories(Spring Boot背后的插件系统)。在实践中,这意味着:

  • 在运行时执行的 Spring 基础结构更少
  • 在运行时要判断的条件更少
  • 减少反射,因为使用的是编程式bean注册

AOT 引擎根据标记为活动的 Bean、Spring 编程模型的知识以及与 Spring Native 捆绑在一起或由应用程序本身提供的native hint,来推断出将应用程序编译为本机可执行文件所需的native configuration。

aot architecture


在Java的类型系统中,数组有什么缺陷吗?

2020年2月,王垠吐槽了下Java的类型系统,说:

关于程序员对 Java 类型系统的理解,比较高级的一个面试问题是这样:

王垠原版的代码
1
2
3
4
5
6
public static void f() {
String[] a = new String[2];
Object[] b = a;
a[0] = "hi";
b[1] = Integer.valueOf(42);
}

这段代码里面到底哪一行错了?为什么?如果某个 Java 版本能顺利运行这段代码,那么如何让这个错误暴露得更致命一些?
注意这里所谓的「错了」是本质上,原理上的。

那么这儿的“错误”是指什么呢?

TL;DR

如果只能用一句话回答这个问题的话,那么就是:

Java数组不支持泛型,破坏了Java的类型安全性

类型系统的一些前提

一个好的类型系统,能够尽可能早的检测出错误,比如你将一个String赋值给int变量的时候,编译器就会报错,而不是等程序跑起来再报错。


如何优雅关闭maven-default-http-blocker?

最近升级Maven到3.8.1后,mvn编译的时候总是提示拉不到依赖,报错如下:

Could not validate integrity of download from http://0.0.0.0/...

全部报错如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[WARNING] Could not validate integrity of download from http://0.0.0.0/com/alibaba/nacos/nacos-client-mse-extension/1.4.2-SNAPSHOT/maven-metadata.xml
org.eclipse.aether.transfer.ChecksumFailureException: Checksum validation failed, expected <!doctype but is 18420d7f1430a348837b97a31a80e374e3b00254
at org.eclipse.aether.connector.basic.ChecksumValidator.validateExternalChecksums (ChecksumValidator.java:174)
at org.eclipse.aether.connector.basic.ChecksumValidator.validate (ChecksumValidator.java:103)
at org.eclipse.aether.connector.basic.BasicRepositoryConnector$GetTaskRunner.runTask (BasicRepositoryConnector.java:460)
at org.eclipse.aether.connector.basic.BasicRepositoryConnector$TaskRunner.run (BasicRepositoryConnector.java:364)
at org.eclipse.aether.util.concurrency.RunnableErrorForwarder$1.run (RunnableErrorForwarder.java:75)
at org.eclipse.aether.connector.basic.BasicRepositoryConnector$DirectExecutor.execute (BasicRepositoryConnector.java:628)
at org.eclipse.aether.connector.basic.BasicRepositoryConnector.get (BasicRepositoryConnector.java:235)
at org.eclipse.aether.internal.impl.DefaultMetadataResolver$ResolveTask.run (DefaultMetadataResolver.java:573)
at org.eclipse.aether.util.concurrency.RunnableErrorForwarder$1.run (RunnableErrorForwarder.java:75)
at java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1130)
at java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:630)
at java.lang.Thread.run (Thread.java:832)
[WARNING] Checksum validation failed, expected <!doctype but is 18420d7f1430a348837b97a31a80e374e3b00254 from maven-default-http-blocker for http://0.0.0.0/com/alibaba/nacos/nacos-client-mse-extension/1.4.2-SNAPSHOT/maven-metadata.xml
Downloaded from maven-default-http-blocker: http://0.0.0.0/com/alibaba/nacos/nacos-client-mse-extension/1.4.2-SNAPSHOT/maven-metadata.xml (63 kB at 19 kB/s)

从关键字maven-default-http-blocker可以找到相关资料。

简而言之,如果使用HTTP协议下载依赖,可能会导致中间人攻击。比如,本来想下载一个nacos-client的,结果下载的结果中被插入了恶意代码,然后开发人员运行了一下,黑客就能获得开发人员的计算机控制权了。

所以Maven 3.8.1就禁止了所有HTTP协议的Maven仓库。

详情见Maven 3.8.1的发布日志

问题是在日常开发中,我们经常会用到公司内部的maven仓库。这些仓库一般都是http协议,Maven 3.8.1禁止了http协议,那么就会导致开头的报错。

于是查了下,可以按照如下方式关闭:


虚引用真的不影响对象的生命周期吗?

Java的四大引用,大家都很熟悉吧:

  • 强应用:正常代码中的引用。一个对象能通过强应用访问到,那它就永远不会被回收
  • 软引用:比强引用弱一级的引用,内存不足时引用指向的对象会被回收
  • 弱引用:比软引用弱一级的引用,下一次GC时指向对象会被回收
  • 虚引用

最后一个虚应用是今天要讨论的。很多文章都是这么写的:

一个对象是否有虚引用存在,对其生存不会产生任何影响。

事实上,这个是错的。正确的表述是:

在Java 8以及之前的版本中,在虚引用回收后,虚引用指向的对象才会回收。在Java 9以及更新的版本中,虚引用不会对对象的生存产生任何影响。

一个示例

首先用Java 8,带上-Xmx10m -XX:+HeapDumpOnOutOfMemoryError参数运行如下代码:

Main.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public final class Main {

public static void main(String[] args) throws InterruptedException {
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
PhantomReference<byte[]> ref = new PhantomReference<>(new byte[1024 * 1024 * 5], queue);

System.out.println(queue.poll());
System.out.println("第一次gc");
System.gc();
Thread.sleep(300L);
System.out.println(queue.poll());
System.out.println("第二次gc");
System.gc();
byte[] bytes1 = new byte[1024 * 1024 * 6];
System.out.println("ending");
}
}

你猜猜结果是什么?


如何从Spring Cloud Config迁移到阿里云ACM

从零开始使用Spring Cloud Config中,我们简单了解了下Spring Cloud Config,它提供了一套配置管理的解决方案。

但是Spring Cloud Config需要自己搭建config-server,还需要结合eureka来实现高可用;如果需要实时更新配置,还需要Spring Cloud Bus。实在是过于繁琐。

而阿里云ACM(应用配置管理)则提供了一整套完整的解决方案:

  1. 与Spring生态紧密集成
    和Spring Cloud Config一样,ACM通过Envirment和PropertySource与Spring结合。
  2. 配置简单
    只需要在ACM上开一个namespace,然后配置client,就能享受到获取配置、自动更新配置功能。
  3. 免费

阿里云ACM相比与Spring Cloud Config,提供了一揽子配置解决方案,不需要在借助Spring Cloud Bus和eureka了。

我们以上一篇文章中的spring-cloud-config-client作为示例。

迁移配置文件

在使用ACM之前,我们需要将现有的配置迁移到ACM中,要不然直接切换过去没法获取任何配置,会导致应用无法正常运行。

为每个环境(此处的环境就是Spring中的profile)创建一个ACM的命名空间。

比如dev对应namespace dev,test对应test命名空间。


从零开始使用Spring Cloud Config

在传统的Java应用中,应用配置都是直接存储在代码库中(比如放在application.yml中),配置的更新需要修改发布包并重新发布。

而配置的审计也会和代码夹杂,需要从众多提交中中找到配置变更的提交,很难独立完成,也很难追溯何时修改了某一项配置。

随着微服务的发展,服务越来越多、配置也越来越多,这种原始的配置管理方式,很难适应分布式环境。作为开发者,还是需要配置能单独地管理和审计。

Spring Cloud Config就提供了配置管理和审计的解决方案。

Spring Cloud Config有如下特点:

  1. 与Spring生态紧密集成
    提供了和Spring中的EnvironmentPropertySource一致的抽象,能够和原有的配置文件很好的对应起来。
  2. 可以动态更新
    只需要添加注解,不需要重启就能更新配置项
  3. 支持多种provider
    配置仓库可以是git仓库、本地文件、数据库等等。

开始使用Spring Cloud config

Spring Cloud Config 分为两部分:

Spring Cloud Config Server负责从配置仓库中获取配置,并给client提供获取配置的HTTP接口

Spring Cloud Config Client负责给目标应用提供从config-server获取配置、更新配置的能力。


Jackson如何反序列化Java14中的record类型?

Java14近日发布,其中引入了新的record类型,虽然这是个预览特性,但是也不妨碍我们尝试下。

record类型

record类型和普通的类相比,有几个特点

  1. 每个字段都是private final的,类本身是final
  2. 类只有一个所有传递所有参数的构造函数
  3. 每个字段会自动会有一个get方法,但是方法名和字段名一致(没有get前缀
  4. 可以给字段添加注解,支持FIELD、METHOD和PARAMETER(如果一个注解是FIELD、METHOD和PARAMETER的,那么注解会在字段、get方法和构造函数参数上同时出现)
  5. toStringequalshashCode方法都是自带的

这几个特点决定了这个非常适合做DO(Model),DTO(比如RPC中定义的各种entity,http返回的json)。

由于业务中经常用到jackson来序列化反序列化对象,今天我们就来试一试jackson对record类型的支持。

如何使用jackson反序列化record对象

如果我们按照原来的方式来反序列化record(如果字段上没有@JsonProperty注解的话),会直接报错:
InvalidDefinitionException: Cannot construct instance of Person (no Creators, like default construct, exist)

这是因为jackson没法找到构造函数和字段的映射,所以我们自己指定默认的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
// from https://gist.github.com/youribonnaffe/03176be516c0ed06828ccc7d6c1724ce
JacksonAnnotationIntrospector implicitRecordAI = new JacksonAnnotationIntrospector() {
@Override
public String findImplicitPropertyName(AnnotatedMember m) {
if (m.getDeclaringClass().isRecord()) {
if (m instanceof AnnotatedParameter parameter) {
return m.getDeclaringClass().getRecordComponents()[parameter.getIndex()].getName();
}
}
return super.findImplicitPropertyName(m);
}
};
objectMapper.setAnnotationIntrospector(implicitRecordAI);

Java 14中的 JEP 358: Helpful NullPointerExceptions是如何实现的

Java 14在2020年3月17号正式发布,不久AdoptOpenJDK也跟进发布了新版。

JEP 358: Helpful NullPointerExceptions

其中对使用者比较有用的功能是 JEP 358: Helpful NullPointerExceptions,即在NullPointerException的message中指明为什么产生了这个NPE。

示例代码如下(需要Java14,并配置参数-XX:+ShowCodeDetailsInExceptionMessages):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class Main {
static class Class1 {
Object object;
}
static class Class2 {
Class1 class1;
public Class1 getClass1() {
return class1;
}
}
static Class2 class2;
private static Object get(int i) {
return null;
}
// 打印NPE的message
private static void printNPE(Runnable runnable) {
try {
runnable.run();
} catch (NullPointerException e) {
System.err.println(e.getMessage());
}
}
public static void main(String[] args) {
Class2 class21 = null;
printNPE(() -> {
// 1. getstatic为null
// Cannot invoke "Main$Class2.getClass1()" because "Main.class2" is null
System.out.println(class2.getClass1().object.toString());
});
Class2 class22 = new Class2();
printNPE(() -> {
// 2. 调用函数结果为null
// Cannot read field "object" because the return value of "Main$Class2.getClass1()" is null
System.out.println(class22.getClass1().object.toString());
});
class22.class1 = new Class1();
printNPE(() -> {
// 3. 函数调用后,getfield为null
// Cannot invoke "Object.toString()" because "Main$Class2.getClass1().object" is null
System.out.println(class22.getClass1().object.toString());
});
printNPE(() -> {
// 4. 链式getfield
// Cannot invoke "Object.toString()" because "class22.class1.object" is null
System.out.println(class22.class1.object.toString());
});
Integer[] ary1 = null;
printNPE(() -> {
// 5. 对null执行aaload
// Cannot load from object array because "ary1" is null
System.out.println(ary1[0].intValue());
});
Integer[] ary2 = new Integer[1];
printNPE(() -> {
// 6. iconst_0+aaload后为null
// Cannot invoke "java.lang.Integer.intValue()" because "ary2[0]" is null
System.out.println(ary2[0].intValue());
});
printNPE(() -> {
int i = 0;
// 7. iconst_0+iload_1后为null
// Cannot invoke "java.lang.Integer.intValue()" because "ary2[i]" is null
System.out.println(ary2[i].intValue());
});
printNPE(() -> {
int i = 0;
// 8. iconst_0+iadd后为null
// Cannot invoke "java.lang.Integer.intValue()" because "ary2[...]" is null
System.out.println(ary2[i + 0].intValue());
});
printNPE(() -> {
// 9. invokevirtual结果为null
// Cannot invoke "Object.toString()" because the return value of "Main.get(int)" is null
System.out.println(Main.get(1).toString());
});
}
}

令我比较意外的是情况4和情况7,Java能够追溯链式回调,而且连变量名都带上了!

JEP 358实现细节

那我们就看下这么神奇的特性是如何实现的吧。

首先,这个功能需要添加参数-XX:+ShowCodeDetailsInExceptionMessages才能开启,那我们只要在JDK的代码中搜索这个字符串就行。于是找到给NPE填充Message的代码:


jdk 8u91一个lambda类型推断的bug

公司内部发布maven包是在公司构建服务器上编译的。最近一次上线,发现同样的代码,在本地的jdk8上能编译通过,但是在构建服务器上,就编译报错。

简化后的代码如下:

JavaBugTest.java
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.Optional;

public class JavaBugTest {

public static <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback) throws E {
return retryCallback.doWithRetry(new Object());
}

public static void main(String[] args) {
Optional.ofNullable(execute((param) -> new Object()))
.ifPresent(s -> System.out.println(s));
}
}
RetryCallback.java
1
2
3
public interface RetryCallback<T, E extends Throwable> {
T doWithRetry(Object var1) throws E;
}

用maven编译报错如下:

1
JavaBugTest.java:[10,36] unreported exception E; must be caught or declared to be thrown

最后发现,是构建服务器jdk版本太老导致的。

二分法尝试了几个版本,发现8u91不行,但是8u92就能编译通过了。

于是开始调试javac代码,发现两个版本的AST不一样:


HashMap的初始容量设置

先说结论

知道大小的情况下,new HashMap的时候这么写:

1
HashMap<Integer, String> map = Maps.newHashMapWithExpectedSize(expectedSize);

正文

Java中的HashMap大家都很熟悉,其底层使用了Node数组来存储Map中的数据。但是如果存储的数据太多,空间不够,就需要扩容这个数组来存储新的数据了。

扩容的实现可以看下java.util.HashMap#resize函数,基本上就是将数组中的内容逐个复制到新数组中。

扩容操作的时间复杂度是O(n),空间复杂度是O(n),还需要计算对象的hash。在平常编码中,如果我们提前知道map的大小,就应该指定初始容量,避免发生扩容。

《阿里巴巴开发手册》中也有这么一条建议:

于是,很多开发者(包括刚开始的我),就这么写:


Robert Lu

关注我的公众号