Java云原生开发初体验报告

背景

前段时间在考虑做一款小工具,功能非常简单,调用多个HTTP接口,分析处理返回的数据,生成Excel文件即可。 为了尽量的让这个工具的实用性更高,我首先想到Java的云原生开发方案,直接构建为可执行文件,不需要使用的人再去安装jre运行环境,或者是带着庞大的jre文件发出。再者,我也想试试Java的云原生方案到底好不好用。

技术选型

因为一直在使用Spring开发业务,所以我这次直接使用了Spring Native

开发过程

注:均基于 macOS

开发环境安装

Spring官网的文章写的非常好,直接参考官文就可以了。Spring官方文档 我选择安装了sdk man,然后

1
sdk install java 22.1.r11-nik

剩下的就是直接用spring native创建一个项目就可以了,这个非常简单。

编译构建

  1. 如果你用了阿里云的maven仓库,请记得切换成apache的原库

  2. macOS默认mvn命令指定的jdk版本可能不是你新安装的GraalVM(像我本地就有七八个不同的JDK版本),会出现一些编译错误,那么你需要创建~/.mavenrc文件

1
2
// 路径记得自己改成正确的
export JAVA_HOME=/Users/baofeidyz/.sdkman/candidates/java/current

报错集锦

报错1

1
2
3
gu install native-image
Downloading: Component catalog from download.bell-sw.com
Error: Unknown component: native-image

在高版本里面,这个native-image好像不需要手动安装了(也有可能是我之前安装过)Install fails in 19.2.0 with gu · Issue #1665 · oracle/graal · GitHub 然后我直接运行了一下native-image发现已经有了

报错2 反射错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.baofeidyz.XXXDTO` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: java.util.ArrayList[0])
at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904)
at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400)
at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1349)
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1415)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:351)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:355)
at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244)
at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:28)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4650)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2856)
有省略部分堆栈

这个报错只有在GraalVM下运行时才会出现,我最开始以为是因为我用了lombok然后导致的编译问题,同时我也有注意到,Spring Native本身是直接带了lombok的,我觉得不太可能吧。 但是我还是尝试去掉了lombok,当然并没有什么用。

然后我又开始在网上寻找一些蛛丝马迹,看看有没有其他人也遇到了类似的问题。但是Java Native问题的搜索真的是非常难找,因为这类问题有可能因为其他的原因也会出现,并且由于Spring Native使用人非常的少,你很难在Google的结果中找到真正的答案。我也尝试使用site:github.com再去Google搜索,仍没有找到答案。 我甚至都想去提issue了 😂 GitHub - graalvm/native-build-tools: Native-image plugins for various build tools

我后来咨询了一位做过Spring Native开发的朋友,他告诉我,可能是因为反射导致的,需要增加配置。 我顺着这位朋友的思路,去看了一下反射配置的问题,正好在这篇文章中给出了一个示例

1
2
3
@TypeHint(types = Data.class, typeNames = "com.example.webclient.Data$SuperHero") 
@SpringBootApplication
public class SampleApplication { // ... }

我顺着这个例子,修改了一下代码

1
2
3
4
5
@TypeHint(types = XXXDTO.class, typeNames = "com.baofeidyz.XXXDTO")  
@SpringBootApplication
public class MySpringNativeApplication {
// ...
}

确实解决了这个jackson反序列化导致的错误,但我又遇到了新的错误。

报错3 缺少字符集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java.nio.charset.UnsupportedCharsetException: CP1252
at java.nio.charset.Charset.forName(Charset.java:529)
at org.apache.poi.util.LocaleUtil.<clinit>(LocaleUtil.java:56)
at org.apache.poi.ss.usermodel.DataFormatter.<init>(DataFormatter.java:242)
at org.apache.poi.ss.usermodel.DataFormatter.<init>(DataFormatter.java:233)
at org.apache.poi.ss.formula.functions.TextFunction.<clinit>(TextFunction.java:33)
at org.apache.poi.ss.formula.atp.AnalysisToolPak.createFunctionsMap(AnalysisToolPak.java:82)
at org.apache.poi.ss.formula.atp.AnalysisToolPak.<init>(AnalysisToolPak.java:47)
at org.apache.poi.ss.formula.atp.AnalysisToolPak.<clinit>(AnalysisToolPak.java:33)
at org.apache.poi.ss.formula.udf.AggregatingUDFFinder.<clinit>(AggregatingUDFFinder.java:35)
at java.lang.Class.ensureInitialized(DynamicHub.java:518)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:155) ~[na:na]
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:226) ~[na:na]
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:214) ~[na:na]
// 有省略堆栈信息

这个看起来像是poi在操作的时候,遇到了一个chartset问题,spring有一个专门的仓库我在这个仓库里面找到了这个issue,我看到一个人也遇到了这个charset的问题,但是他是mysql的时候遇到的,他提到加了一个参数用于解决这个问题。 紧接着,我又找到了几个issue:

了解到说,需要在native-image编译的时候,增加一个AddAllCharsets参数 但是我是使用的spring native的maven插件,我的构建命令是mvn -Pnative,我并没有直接使用native-image,我不知道怎么增加这个参数才是对的。 此外,我还看了两份文档:

终于,我知道如何在Spring Native中增加这个AddAllCharsets参数了 😄

1
2
3
4
5
6
7
@TypeHint(types = XXXDTO.class, typeNames = "com.baofeidyz.XXXDTO")  
// 👇 下面这行代码就解决了我的问题
@NativeHint(options = "-H:+AddAllCharsets")
@SpringBootApplication
public class MySpringNativeApplication {
// ...
}

最佳实践就是加一个@NativeHint(options = "-H:+AddAllCharsets") 当然不可能这么顺利的,很快我又遇到了新的报错

错误4 POI的resource文件未加载

1
2
3
4
5
6
7
8
9
10
11
12
org.apache.xmlbeans.SchemaTypeLoaderException: XML-BEANS compiled schema: Could not locate compiled schema resource org/apache/poi/schemas/ooxml/system/ooxml/index.xsb (org.apache.poi.schemas.ooxml.system.ooxml.index) - code 0
at org.apache.xmlbeans.impl.schema.XsbReader.<init>(XsbReader.java:63)
at org.apache.xmlbeans.impl.schema.SchemaTypeSystemImpl.initFromHeader(SchemaTypeSystemImpl.java:235)
at org.apache.xmlbeans.impl.schema.SchemaTypeSystemImpl.<init>(SchemaTypeSystemImpl.java:201)
at org.apache.poi.schemas.ooxml.system.ooxml.TypeSystemHolder.<init>(TypeSystemHolder.java:9)
at org.apache.poi.schemas.ooxml.system.ooxml.TypeSystemHolder.<clinit>(TypeSystemHolder.java:6)
at org.openxmlformats.schemas.spreadsheetml.x2006.main.CTWorkbook.<clinit>(CTWorkbook.java:22)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.onWorkbookCreate(XSSFWorkbook.java:475)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:232)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:226)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:214)
// 有省略

我猜测应该是因为资源没有引入导致的,所以我理解应该还是应该用spring native提供的hint去实现。我参考这个代码块 增加了一个@ResourceHint

1
2
3
4
5
6
7
@TypeHint(types = XXXDTO.class, typeNames = "com.baofeidyz.XXXDTO")  
@ResourceHint(patterns = {"(^/|[a-zA-Z])*/.+(/$)?.xsb"})
@NativeHint(options = "-H:+AddAllCharsets")
@SpringBootApplication
public class MySpringNativeApplication {
// ...
}

报错5

1
2
3
4
5
6
java.lang.ClassCastException: org.apache.xmlbeans.impl.values.XmlComplexContentImpl cannot be cast to org.openxmlformats.schemas.spreadsheetml.x2006.main.CTWorkbook
at org.apache.poi.xssf.usermodel.XSSFWorkbook.onWorkbookCreate(XSSFWorkbook.java:475)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:232)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:226)
at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:214)
// 有省略

然后我在GraalVM的issue清单中找到了这个issue:Getting following error while generating excel file using native image. · Issue #1929 · oracle/graal · GitHub 基本上错误信息就是一致的。 我试了一下,不行。

我感觉POI和GraalVM配合坑有点多,我不打算一个一个找了,我尝试去找一些大而全的文章,于是,我找到了这篇文章:Graalvm使用采坑 - 行万里路才能回到内心深处,读万卷书才能看得清皓月繁星

可惜还是失败了,我感觉算了吧,POI和GraalVM的路还很长哦…

一些思考

  1. 对于这种POI组件的问题,处理起来确实很难。我查了GraalVM的ISSUE清单,5.0之前的POI确实是有一些问题,但与此同时GraalVM可能需要大量的构建参数去配置,然后Spring Native又在其中转了一次。也就是说,我需要知道这个问题不是POI导致的,然后去看native-image对应的解决方案(配置参数)是什么,然后再转成Spring Native的hints。如果我不能确定这个问题是不是POI导致的,我连提ISSUE给谁都不清楚,如果自己尝试去排查编译问题,这就会进入一个更深的坑。

  2. 我建议如果Spring Native example下找不到对应的例子之前,不要去趟这个浑水,整个开发过程非常阻塞,问题很难说真正的解决完。

  3. Java云原生的路确实很难走,不管是GraalVM去适配组件,还是组件去适配GraalVM,整个适配时间会拉得很长很长。

  4. GraalVM编译时间很长,我的项目并不大,4核8线程,16G内存,但是每次都需要大概3分钟左右,并且编译的时候,CPU使用率一直都很高。

后续

我后来用GO重写了整个程序,这是我第一次用GO,从0开始学,大概花了20个小时就写完了,并且我还用了协程并发,处理了一些线程安全的问题。相比之下,Spring Native至少花了我四倍的时间,并且没有做完。 GO编译速度非常快,我交叉编译linux或windows也才10s,CPU使用率也不高,相比之下,GO做这类小工具的云原生开发真的是非常好用。

所以我在怀疑,Java云原生是否真的有意义?希望十年后,能被打脸。

以上就是我Java云原生的初步体验报告,如果对你有一点点帮助,希望可以给我的文章点个赞和收藏。

NanoPi R2S 软路由折腾记

😩 问题多多

最开始用的是YouTuber 洋葱提供的固件,但是存在一些问题:

  1. TF卡的空间没有挂载完(8G的卡,只用到了1G左右),自己用fdisk挂载逻辑分区报错;
  2. 修改docker默认存储位置后,镜像依赖的一些程序无法使用,如curl等;
  3. 直接opkg install安装固件总会出一些莫名其妙的依赖问题,opkg update也好,还是直接ipk安装,都会遇到很多奇奇怪怪的问题;

🧐 有机会?

接着,我就去openwrt官网找了镜像,但无论是用工具balenaEther刷入,还是使用dd命令写入。当在TF卡插回R2S时,SYS灯一直红色长亮,wan和lan的灯无任何响应。
由于R2S本身还有挑卡的毛病,不太确定是不是镜像本身存在问题;

😋 解决啦~

后面意外发现其实友善也有自己的官网,在其wiki上找到了镜像下载地址
下载刷入以后,正常启动。

我用的是balenaEther,但使用dd应该也是可行的

docker也可正常使用。当然还是遇到了一些小问题。

🐛 一些小问题

openclash订阅更新一直失败

安装好openclash插件以后,在更新订阅时,一直提示失败。

DNS无法解析

常见DNS无法解析,直接写到etc/hosts,使用ping或者nslookup命令验证解决

curl命令缺少依赖文件

openclash下载文件也是依赖于wget或者curl之类命令,我ssh连接到路由器以后,测试了一下这两个命令是否可正常下载文件,结果到curl时,发现缺少了部分so依赖文件,使用opkg install安装对应的包就解决了。
参考:libwolfssl5.2.0.99a5b54a_5.2.0-stable-2_x86_64.ipk OpenWrt 21.02 Download

🥶 继续折腾HASS

未完待续…

Docker容器一启动就挂,要怎么排查?

1. 分析镜像信息

通过

1
docker inspect 镜像ID

可以查看当前镜像的属性信息,那么我们重点需要关注的是Entrypoint这个属性的信息。
关于entrypoint是什么,你可以参考Dockerfile reference | Docker Documentation

我这里获取到的Entrypoint信息如下:

1
2
3
"Entrypoint": [
"/docker-entrypoint.sh"
],

这个Entrypoint可以说是非常的简单,但是又非常的复杂。
简单在于就一行,是直接去执行/docker-entrypoint.sh这个脚本,但是复杂在于我们并不能直接看到这个docker-entrypoint.sh的内容。

2. 分析Entrypoint

一般来说,我们有几种方法可以看到这个/docker-entrypoint.sh文件:

  1. 挂载:通过docker run -v将这个目录挂载到宿主机上,就算这个容器一启动就死,我们也不用担心拿不到文件;
  2. 交互命令(这个词是我自己想的,可能不是很准确):通过docker run -it 镜像ID sh -c "cat /docker-entrypoint.sh"去查看内容。这个方法其实还是需要容器启动的,如果是启动立刻挂的情况,这个方法应该是会失效的;
  3. 跳过entrypoint:通过`docker run –entrypoint sh 镜像ID -c “cat /docker-entrypoint.sh”,即可覆盖镜像中的entrypoint,覆盖以后,容器并没有去运行entrypoint脚本,所以也就不会挂了。

3. 覆盖entrypoint

既然都可以覆盖entrypoint了,那我们为啥要局限于通过交互命令去查看文件呢?

1
docker run -d --entrypoint sh 镜像ID -c "ping 127.0.0.1"

通过这个命令,给容器一个不可完成的任务,那么这个容器将一直存活。然后再通过

1
docker exec -it 容器ID sh

进入容器,岂不是美哉?

如果你的镜像比较简单,可能会没有ping命令,那么你可能需要tail或者其他命令来保证容器可长期存活

4. 手动执行entrypoint

容器不会被自动杀掉以后,我们就可以一步一步的去分析这个entrypoint到底是死在哪儿了?

这里就没有办法展开讲了,因为每个人的情况都是不同的,只要有点耐心,我相信一定是可以解决的。

Docker(K8S)环境下开启JMX远程监控

问题引入

JMX(即Java Management Extensions),如果你在网上搜索如何配置JMX,你就会看到这样的一堆配置

1
2
3
4
5
6
-Djava.rmi.server.hostname=
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.rmi.port=
-Dcom.sun.management.jmxremote.port=
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false

然后你就会发现在docker下,怎么弄都不对。

JMX分析

JMX其实需要注册三个端口,其作用为:

  • 端口1: 接收注册请求,JMX客户端(如jvisualvm)在连接时,需要填写的端口号

  • 端口2: 用于远程连接,可以与端口1使用同一个端口号

  • 端口3: 用于本地连接,随机(这个端口在实际业务场景中,不需要去指定,我暂时没研究如何指定这个端口)

排坑

坑点1 JMX端口仅暴露1个

由于JMX是在远程监控的情况下是需要两个端口的,所以在Docker环境下很可能就会出现因为端口没映射全而导致的失败

解决办法也很简单,就是把端口1和端口2设置为同一个端口即可,这样也可以减少端口映射过多而产生的端口冲突

1
2
-Dcom.sun.management.jmxremote.rmi.port=8888
-Dcom.sun.management.jmxremote.port=8888

注:8888这个端口是我随便写的,在合法范围内都可以

坑点2 java.rmi.server.hostname配置导致JMX连接失败

坑点1其实在网络上已经有大把大把现成的文章了,而对于坑点2,则在于java.rmi.server.hostname这个配置。我个人认为这个是因为JMX诞生比较早,所以JMX并没有适配容器化,这就导致了第二个坑点。

java.rmi.server.hostname配置失败的体现在于,你的JMX客户端,会在连接中卡顿很久,这其实就是JMX尝试连接,但连接不上导致的。

JMX客户端根据你填写的IP和PORT去查找JMX服务,这时候你的服务端会返回这个java.rmi.server.hostnamecom.sun.management.jmxremote.rmi.port,JMX客户端再根据这两个值去连接。

那么在docker环境下,你配置的java.rmi.server.hostname很可能就配置错了,你需要保证这个java.rmi.server.hostname是客户端直接可连的,并且这个com.sun.management.jmxremote.rmi.port也是可以直接访问的。

我使用的环境是我用我的笔记本去监控运行在服务器K8S集群中的服务,这种情况下,我需要把java.rmi.server.hostname配置为这个集群所使用的映射服务IP,

如果你的映射服务IP过多,这就会很麻烦,因为每次修改java.rmi.server.hostname需要重启,而重启等同于重新部署,这就导致有可能不匹配,这个问题可能需要想办法绑定,或者自己手动用kubectl做映射

并且你还需要保证com.sun.management.jmxremote.rmi.port是映射服务所使用的端口。

创建端口映射的时候,就保证你的容器端口和映射端口得是一致的,不然JMX服务返回给JMX客户端的端口,没办法通过端口映射连接了

![在这里插入图片描述](https://img-blog.csdnimg.cn/129b48695cbd44c5a6c6d0f346c23666.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAYmFvZmVpZHl6,size_20,color_FFFFFF,t_70,g_se,x_16 align=”left”)

参考

how-to-connect-with-jmx-from-host-to-docker-container-in-docker-machine

不要直接复用其他业务的线程池

前言

最近在复查团队小伙伴的代码时发现,错误复用了一个定时触发信息同步的线程池。但他开发的代码所对应的业务场景是响应前端页面的请求。而这次的线程池复用将可能会导致系统页面“卡死”。

信息同步的线程池,其主要配置信息为:

  • corePoolSize:4
  • maximumPoolSize:8
  • keepAliveTime:30L
  • unit:TimeUnit.SECONDS
  • workQueue:new ArrayBlockingQueue<>(1000)
  • threadFactory:new com.google.common.util.concurrent.ThreadFactoryBuilder().setNameFormat("xxx-pool-%d").build()
  • handler:new ThreadPoolExecutor.CallerRunsPolicy()

这个定时任务设定为每半个小时执行一次,当定时任务开始执行后,这个队列任务很快就会装满。

如果这个时候响应前端页面请求的线程进入,就会进行等待队列,此时可能会发生:

  1. 任务队列满,触发丢弃策略,前端页面请求被丢弃,用户拿不到的正确的数据;
  2. 任务较多,前端页面请求等待,可能会出现前端页面请求超时,系统“卡死”;

分析

存在任务优先级存在问题。页面请求的优先级应大于信息同步任务的优先级,但如果复用同一个线程池,那么在任务执行顺序上,就是先进先执行。

如果此前已经有多个信息同步任务正在等待,页面请求也必须要等到信息同步任务执行完成以后才可以去与其他线程抢占系统资源。

结论

对于一些优先级较高的任务,应当独立维护线程池。虽然在JVM中还存在线程调度问题,但至少不会一直阻塞去等待其他任务的执行。

对于一些优先级较低的定时任务,可以考虑适当复用,与此同时也需要考虑好核心线程数、最大线程数、等待队列、丢弃策略等。

很多技术解决方案都是一把双刃剑,用得好,事半功倍,用不好,系统宕机😂

解决macOS Big Sur升级后部分java应用无法打开的问题JavaVM: Failed to load JVM: libserver.dylib

升级到macOS Big Sur以后,之前安装的dbeaver和mat都无法打开了,点击报错都是同一个问题。

实际上oracle jdk在安装完成以后是没有 libserver.dylib 这个文件的,但是dbeaver和mat还是在查找这个文件,应该是出兼容性bug了。

解决的方案很简单,就是要找到这个 libserver.dylib 对应应该是什么文件就可以了。几番折腾之下,我在这里找到了答案,实际的地址应该是

1
/Library/Java/JavaVirtualMachines/jdk1.8.0_271.jdk/Contents/Home/jre/lib/server/libjvm.dylib

使用ln -s创建一个链接就可以解决了。

1
sudo ln -s /Library/Java/JavaVirtualMachines/jdk1.8.0_271.jdk/Contents/Home/jre/lib/server/libjvm.dylib /Library/Java/JavaVirtualMachines/jdk1.8.0_271.jdk/Contents/Home/lib/libserver.dylib

如果你的jdk文件夹和我的命令不一样,请记得修改命令。

升级之前为啥没有这个问题,我就不知道了,很有可能我之前创建过链接被macOS给删掉?(可能性不大吧)

git仓库从30G压缩到600M-git文档仓库过大的优化方案

项目上使用git管理相关文档,因维护多年,导致其文档仓库已经逼近30G的大小,对于我这台256G的MBP来说,无疑是占用了巨大的空间,介于此,我计划减少这个仓库的体积。

git-lfs

git-lfsatlassian维护的开源项目,意图解决因反复修改大文件而导致git仓库变大,以及影响初次下载的体验;

在我尝试使用git-lfs后,占用空间被压缩到了24G左右,但效果并不明显,这主要是因为这个git仓库中的大部分文档都不存在反复修改的情况,大部分文件都是上传后再也没有改过。

可以说git-lfs解决了我一部分的问题,但并没有全部解决。

GVFS

GVFS是微软的开源项目,意图解决git对于超大型仓库的维护问题,但很可惜,GVFS依赖于window操作系统,我手上主力机还是macOS,所以GVFS就不考虑了。

git sparse checkout

sparse checkout是git自己维护的功能,其提供的功能是告诉git相关的命令再拉取时仅拉取部分文件,或排除掉部分文件。

很现实,sparse checkout就是解决我这个问题的最佳方案

使用方法

  1. 新建仓库并设置远端仓库地址
1
2
git init
git remote add -f origin <url>
  1. 开启配置
1
git config core.sparsecheckout true
  1. 配置想要拉取的文件路径
1
xxx/xxx/**

或者是不想要拉取的文件路径

1
!yyy/**

然后正常使用git pull拉取即可

  1. 注意使用较新版本的git;

  2. 使用方法也可以参考[官网链接](git config core.sparsecheckout true)

结论

最终我使用的是git-lfs搭配git parse checkout搭配使用,一方面降低大文件重复修改所产生的占用,一方面只需要拉取我自己需要的文件到本地即可。

这样操作以后,原30G的git仓库,我现在只需要600M就可以了~

Java应用被k8s认定为oom杀掉

前不久现场反馈说服务运行一段时间就重启了,希望我介入排查一下。

先说结论

jvm堆大小与k8s pod设置的大小一致,均为4g。因jvm还存在其他的内存占用,pod服务总体的内存占用会超过4g,k8s认定为oom,将其杀掉。以及jvm较低版本没有支持容器namespace的资源隔离。

处理过程

  1. 检查日志

    看服务重启前后日志有无抛出一些较为严重的错误,是否存在因为个别异常导致的服务重启;

  2. 修改jvm堆的大小,增加oom时候自动dump等参数

    1. 怀疑是系统内部有一些“不良”业务导致的堆的大量占用,试想是否可以通过增加堆的大小,再对堆进行快照分析,确定具体占用较大堆内存的业务代码,对其进行定向优化。
    2. 增加-XX:+HeapDumpOnOutOfMemoryError以及-XX:HeapDumpPath=/myPath/heapdump.hprof参数,让jvm在下一次oom时自动导出堆的快照,便于分析。

    通过不停的分析堆的镜像快照,确实是没有任何业务代码过多的或者过量的导致了堆的增长,增加的-XX:+HeapDumpOnOutOfMemoryError也没有生效。

  3. 检查k8s pod信息

    在k8s
    pod信息查到,服务是以oom的原因导致被kill的。经过了解,k8s认定pod的内存占用达到了pod所配置的limit值时,就会判定为oom,然后杀掉,从侧面增加-XX:+HeapDumpOnOutOfMemoryError
    无效的原因。

    检查现场配置时发现,jvm堆的大小和pod的大小限制一致。已知java 8除了堆以外还有其他的内存占用,猜测是不是这部分导致了pod实际内存使用大小超过了pod限制导致。

    修改pod限制参数为6g后,问题得以解决。

后续思考

虽然修改为6g以后,服务不再被k8s以oom的原因kill了,但于此同时,还有一个问题一直萦绕着我。

为什么jvm没有回收内存?

在一番了解后,我了解到Java 1.8有一个更新,在Java 1.8
191这个版本中,Java更新了对于namespace的支持,地址是:https://www.oracle.com/java/technologies/javase/8u191-relnotes.html,对应具体的bug描述是https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8146115

容器是通过linux的namespace做资源隔离,通过pid下的cgroup做资源限制,但是在早先版本中,jvm并没有支持这一特性,从而导致了jvm内存不回收的问题。

jvm指定的堆大小到底

类的加载、链接和初始化(基于Java 1.8)

Java的数据类型(Data type)主要是有两种:

  1. 基本类型 primitive types
  2. 引用类型 reference types

其中引用类型又被细分为:

  1. 类 class types
  2. 数组 array types
  3. 接口 interface types

基本类型和数组类型是由Java虚拟机直接生成的,其他(类 class types、接口 interface types)则需要Java虚拟机对其进行链接和初始化。

1. Java虚拟机启动 Java Virtual Machine Startup

  • 通过引导类加载器创建一个初始类来启动,并执行这个public class中的void main(String[])方法。
  • 初始类可以作为命令行参数提供。或者,该实现可以提供一个初始类,该初始类设置一个类加载器,该类加载器进而加载。

2. 创建和加载 Creation and Loading

  • 指查找字节流,并据此创建类的过程。
  • 其中数组(array types)是没有字节流的,由Java虚拟机直接生成,对于其他的类来说,Java虚拟机需要借助于类加载器来完成查找字节流的工程。

2.1 类加载器 ClassLoader

在Java虚拟机规范中,类加载器被分为两种:

  1. Java虚拟机提供的引导类加载器(bootstrap class loader)
  2. 用户定义的类加载器(user-defind class loaders)

2.1.1 Java虚拟机提供的引导类加载器 bootstrap class loader

  • bootstrap class loader 由Java虚拟机提供的
  • 这个类加载器是使用C++实现的,没有对应的Java对象。

2.1.2 用户定义的类加载器 user-defind class loaders

  • user-defind class loaders 是Java虚拟机规范中对于类加载器的分类划分,是一个统称,实际上并没有这个类加载器
  • 用户定义的类加载器都是java.lang.ClassLoader类的子类
  • 在Java虚拟机规范中提到,用户定义的类加载器可以实现通过网络下载类,动态生成类或从加密文件中提取类
  • 用户定义的类加载器需要由bootstrap class loader去加载
  • 在Java1.8的核心类库中,提供了两个类加载器,分别是:
    1. 扩展类加载器 extention class loader
      sun.misc.Launcher.ExtClassLoader
    2. 应用类加载器 application class loader
      sun.misc.Launcher.AppClassLoader

2.1.2.1 扩展类加载器 extention class loader

  • 扩展类加载器的父是启动类加载器(bootstrap class loader)
  • 负责加载相对次要、但又通用的类,如JRE的lib/ext目录下jar包中的类(以及java.ext.dirs指定的类, 这个可以通过查看sun.misc.Launcher.ExtClassLoader.getExtDirs()方法确认)

2.1.2.2 应用类加载器 application class loader

  • 应用类加载器的父则是扩展类加载器
  • 负责加载应用程序路径下的类(应用程序指虚拟机参数-cp/-classpath、系统变量java.class.path或环境变量CLASSPATH所指定的路径。这个可以通过查看sun.misc.Launcher.AppClassLoader确认)
  • 默认情况下,应用程序中包含的类便是通过应用类加载器加载的。

2.1.3 双亲委派模型

  • 指的是一个类加载器接收到加载请求时,会先将请求转发给父类加载器,在父类加载器没有找到所请求的类的情况下,该类加载器才会去尝试加载。

  • 双亲委派模型可以避免类的重复加载,以及java的核心api被篡改的问题。

3 链接 Linking

  • 指将创建的类合并至Java虚拟机中,使之能够执行的过程。可分为验证(Verification)、准备(Preparation)以及解析(Resolution)三个阶段

3.1 验证 Verification

验证是为了确保被加载的类满足Java虚拟机的约束条件。

3.2 准备 Preparation

  • 准备是为被加载的类的静态字段分配内容。
  • 构造其他跟类层次相关的数据结构:如用来实现虚方法的动态绑定的方法表。

3.3 解析 Resolution

在开始解析之前,需要知道:
class文件被加载到Java虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能无歧义地定位到具体目标上。

  • 解析的目标是将符号引用解析成为实际应用: 如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析就触发这个类的加载。(但未必会出发这个类的链接和初始化)

此外,在Java虚拟机规范中并没有要求在链接过程中完成解析。仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

4. 初始化 Initialization

  • 为标记为常量值的字段赋值,以及执行<clinit>方法的过程
  • Java虚拟机会通过加锁来确保类的<clinit>方法仅被执行一次

常量值解释:
Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,或者在静态代码块中对其进行赋值
如果直接赋值的静态字段被final所修饰,并且它的类型是基本类型或字符串时,该字段便会被Java编译器标记为常量值(ConstantValue)

4.1 初始化的触发条件

在Java虚拟机规范中明确枚举了以下情况:

  • The execution of any one of the Java Virtual Machine instructions new, getstatic, putstatic, or invokestatic that references C (§new, §getstatic, §putstatic, §invokestatic). These instructions reference a class or interface directly or indirectly through either a field reference or a method reference.

  • Upon execution of a new instruction, the referenced class is initialized if it has not been initialized already.

  • Upon execution of a getstatic, putstatic, or invokestatic instruction, the class or interface that declared the resolved field or method is initialized if it has not been initialized already.

  • The first invocation of a java.lang.invoke.MethodHandle instance which was the result of method handle resolution (§5.4.3.5) for a method handle of kind 2 (REF_getStatic), 4 (REF_putStatic), 6 (REF_invokeStatic), or 8 (REF_newInvokeSpecial).

  • This implies that the class of a bootstrap method is initialized when the bootstrap method is invoked for an invokedynamic instruction (§invokedynamic), as part of the continuing resolution of the call site specifier.

  • Invocation of certain reflective methods in the class library (§2.12), for example, in class Class or in package java.lang.reflect.

  • If C is a class, the initialization of one of its subclasses.

  • If C is an interface that declares a non-abstract, non-static method, the initialization of a class that implements C directly or indirectly.

  • If C is a class, its designation as the initial class at Java Virtual Machine startup (§5.2).

Java虚拟机规范中有部分是依赖于Java虚拟机指令了,我对此了解并不多,以下摘抄于《极客时间-深入拆解Java虚拟机-郑雨迪》的分享,相较而言更通俗易懂些。

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射API对某个类进行反射调用时,初始化这个类;
  8. 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。

5. 绑定本机方法实现

指的是将Java编程语言以为的其他语言编写的功能和实现native方法的功能集成到Java虚拟机中以便可以执行的过程。

传统上来说,此过程可称为链接,但Java虚拟机规范中指出,使用绑定是为了避免于Java虚拟机对类和接口的链接产生混淆。

6. Java虚拟机退出

当调用Runtime.exit()Runtime.halt()System.exit,并且SecurityManager安全管理其允许exit或halt时候,Java虚拟机就会被关闭。

同时,JNI(Java Native Interface)规范描述了对于JNI调用相关的java虚拟机的终止信息。

参考文档

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html
https://time.geekbang.org/column/article/11523

HashMap源码实现解析

1
2
3
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)

基于数组+链表实现,通过&与运算,计算数组下标。在JDK8中,加入红黑树实现,使其时间复杂度保持在O(1)到O(logn)

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
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;

Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }

public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}

public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}

public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

HashMap 静态内部类Node,实现链表,通过Node[]这个数组属性存放所有的节点。

put(K,V)

应该直接看final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)这个方法更为实际

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
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

如果当前想要存放的这个节点的hash值暂时没有存在的节点,则直接在数组中添加。

1
2
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

通过&与运算,

如果当前节点的hash值存在了,则在这个节点下增加链表。

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
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}

从JDK8 开始,当链表中的子节点超过八个时,将转为红黑树。关于红黑树的数据结构特点,我现在也不是特别的理解,先给自己挖个坑,改天填。

红黑树

HashMap扩容

HashMap中有一个属性:threshold ,这个主要是根据阀值和当前HashMap的大小计算而来,可通过查看

1
2
3
4
5
6
7
8
9
10
11
12
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

在初始化HashMap的最后,会根据当前的阀值和实际的大小进行计算threshold的值,同时在每一次操作元素的时候,都会去比较当前HashMap的实际大小与threshold的值,如果当前实际大小已经大于了这个限定的阀值,此时将会对HashMap进行扩容。

resize()方法主要是两个步骤:

  1. 计算大小;
  2. 将原HashMap中的元素进行移动

挖坑,以后填

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;

get(Object key)

get方法就更好理解了,首先还是通过hash值找到数组下标,然后通过数组下标获取的实际的元素。然后判断一下当前节点key的hash值是否与第一个节点相同,相同则直接返回结果。

如果不同,这个时候,就得看第一个节点后的下一个节点是采用的红黑树还是使用的链表。然后再根据key的hash去取值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
0%