分类 Java 相关 下的文章

Java 字符串常量池

首先看下面一段代码:

public class StringTest {
    public static final String S = "Hello";

    private String s = "Hello";

    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "Hello";
        String s3 = new String("Hello");
        String s4 = new String("Hello");
        String s5 = new String("Hello").intern();
        String s6 = "Hello".intern();

        StringTest st = new StringTest();
        System.out.println(s1 == st.s); // true
        System.out.println(s1 == StringTest.S); // true
        System.out.println(s1 == s2); // true
        System.out.println(s1 == s3); // false
        System.out.println(s3 == s4); // false
        System.out.println(s1 == s5); // true
        System.out.println(s1 == s6); // true

        System.out.println(s1.equals(s2) + " " + s1.equals(s3) + " " + s3.equals(s4) + " " + s1.equals(s5) + " " + s1.equals(s6));
    }
}

运行在 JDK 17 上.
这是除了 StringTest.S 这个静态字段之外的其他是 "Hello" 的字符串在内存的表示. 可以得出:

  1. 除了 s3, s4 其它都指向同一个内存对象.
  2. 所有这些字符串都指向同一个 byte[], 里面存储的是 “Hello” 的 ascii 码.
    stringAddr.png
    静态字段, 静态字段也同其它字面量一样, 都指向同一个.
    class_static.png

这段代码里面, 有声明字面量(literal) 和 String 对象. 所有的字面量(常量或变量)都是指向内存字符串常量池的同一个字符串地址, 所以使用 == 对比也是相等的.

Java 常量池是什么?

Java 的字符串都是 Immutable 的, 也就是更改都会产生新的字符串. 常量池是在内存中开辟出来的一块专门存储字符串的空间. 由 String 类管理(native 代码). 它是一个很大的 HashMap 维护.

Java 常量池存在的意义什么?

所谓池, 就是共享同一份. 因为对很多程序来说, 很多字符串都是反复使用, 只要内存保存一份就好了.

为什么 new String() 不能返回常量池字符串?

常量池大小调节

可以看到如下参数, 默认是 65536. 调的太小, 容易产生hash 碰撞. 如果字符串不多, 调的太大, 占用太多空间.

uintx StringTableSize                          = 65536                                     {product} {default}

JFR 实战之一

自从2016年左右开始干SRE, 就了解到 JFR 是一个非常有用的工具, 尤其在性能调优这一块. 在好几本书和一些文章中都有人提及过, 但是很少见有人用它来解决实际的问题. 可能的原因包括: JFR的很多事件都是JVM本身暴露的稍底层的一些时间, 不熟悉JVM运行机制的话, 很难从这边比较容易的看出问题. JFR 如果用在生产环境是需要付费的.

为了从理论到实践, 这里就在本地开发环境打开JFR, 然后收集一些数据, 然后解读.

设置环境, 开启JFR

本地正好有个 Java 应用: Pycharm, 通过下面的命令可以看到它的一些启动参数:

$ jcmd
60759 jdk.jcmd/sun.tools.jcmd.JCmd
22348 com.intellij.idea.Main

$ jcmd 22348 VM.command_line

$ jcmd 22348 VM.flags -all | grep FlightRecorder
     bool FlightRecorder                           = false                                     {product} {default}
    ccstr FlightRecorderOptions                    =                                           {product} {default}

我们修改 Pycharm 的 JVM 参数. 从 Pycharm 菜单依次点击: Help -> Edit Custom VM Options. 就打开了 ~/Library/Application\ Support/JetBrains/PyCharmCE2023.3/pycharm.vmoptions. 添加下面选项:

-XX:+UnlockCommercialFeatures
-XX:+FlightRecorder

上面的修改类似于 java -XX:+UnlockCommercialFeatures -XX:+FlightRecorder MyApp

重启 Pycharm, 我们再次检查它的参数:

jcmd 62236 VM.flags -all | grep FlightRecorder
     bool FlightRecorder                           = true                                      {product} {command line}
    ccstr FlightRecorderOptions                    =                                           {product} {default}

收集数据

上面的-XX:+FlightRecorder 选项只是说明我们打开了 JFR 的开关, 真正的收集事件并未开始. 通常我们可以通过 jcmd 命令行或者一些GUI工具(JConsole, Java Mission Control等)来开始收集.

通过运行 jcmd 命令, 我们可以看到有关 JFR 的一些命令, 然后通过 help 能看到对应子命令的更多内容:

$ jcmd <pid> help | grep JFR
JFR.check
JFR.configure
JFR.dump
JFR.start
JFR.stop

$ jcmd 62236 help JFR.start
   ... 可以看到很多参数省略 ...

通过如下的命令, 我们可以启动收集:

$ jcmd 62236 JFR.start name=sample1,filename=/tmp/sample1.jfr,during=1m
62236:
Started recording 1. No limit specified, using maxsize=250MB as default.

Use jcmd 62236 JFR.dump name=sample1,filename=/tmp/sample1.jfr,during=1m filename=FILEPATH to copy recording data to file.

查看这个收集任务 并把数据保存到文件

$ jcmd 62236 JFR.check
62236:
Recording 1: name=sample1,filename=/tmp/sample1.jfr,during=1m maxsize=250.0MB (running)

jcmd 62236 JFR.dump name=sample1,filename=/tmp/sample1.jfr,during=1m filename=/tmp/sample1.jfr
62236:
Dumped recording "sample1,filename=/tmp/sample1.jfr,during=1m", 6.2 MB written to:

/private/tmp/sample1.jfr

关于这几个子命令的更多信息: https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/comline.htm#JFRUH188

解读数据

解读数据需要 Java Mission Control (JMC), 下载页面: https://www.oracle.com/java/technologies/javase/products-jmc8-downloads.html

启动 JMC, 然后打开刚才的文件.
jmc.png

事件类型

JFR 收集3类数据类型:

  1. 持续事件: 需要一段时间才会发生,并在完成时记录。你可以设置持续事件的阈值,以便只记录持续时间超过指定时间段的事件。
  2. 瞬时事件: 立即发生,并立即记录。
  3. 样本事件: 以固定间隔记录,以提供系统活动的样本。你可以配置采样发生的频率。

关于 Java GC 里面的PLAB

之前有篇文章提到过 TALB (Thread-Local Allocation Buffers), 与之类似的概念在年轻代 promote 到 survivor 和 old generation 的时候, 也有 PLAB.

什么是 PLAB

PLAB 是 Promotion Local Allocation Buffers 的缩写, 是 Java 垃圾回收相关的概念. heap 里面通常把heap 分为年轻代和老年代,年轻代又细分为 Eden 区域和 2个 survivor 区域. 当把 Eden 区域的对象 promote 到老年代的时候, 我们称之为 Promotion. 但是在 promote 到老年代之前, 根据设置参数的不同, 可能会现在2个 survivor 区域来回复制几次.

从 Eden 区域复制到 survivor 区域或者 promote 到老年代都是 GC 线程做的事情, 这个时候是 CPU 密集型操作. 并且复制到的目的区域是各个GC 线程同样的目的区域. 这个时候就会出现竞争, 竞争就会出现锁.

为了避免锁竞争, 就要采用分块的概念, 也就是给每个做GC 的线程独自分配一块区域, 是它自己独享的目的区域, 这样就能避免竞争. 于是就有了 PLAB 的概念.

与 PLAB 相关的参数

这是基于 JDK 17 的与 PLAB 相关的参数:

java -XX:+PrintFlagsFinal -version | grep PLAB
   size_t OldPLABSize                              = 1024                                      {product} {default}
    uintx PLABWeight                               = 75                                        {product} {default}
     bool ResizePLAB                               = true                                      {product} {default}
    uintx TargetPLABWastePct                       = 10                                        {product} {default}
   size_t YoungPLABSize                            = 4096                                      {product} {default}

PLAB 与 NUMA

这篇文章讨论了 G1 在 NUMA 情况下如何 promote 对象到老年代的时候, 处理 NUMA 内存相关的问题.
https://sangheon.github.io/2020/11/03/g1-numa.html

PLAB 在 CMS 和 G1 GC 的使用

PLAB 参数的相关概念

诊断 SSL 连接过多造成的CPU过载性能下降

有同事反映他们的应用最近经常出现: CPU 将近100%, tomcat busy thread 达到最大阈值, GC 花费的时间也增加, 最终处理请求变慢. 现象就是下面这种:
state.png

症状分析

GC 其实并不太高, 也就是15%以下, 所以它基本是结果, 不是原因. latency 过高也是结果, tomcat busy thread 变高也是结果, 因为处理变慢, 所以 busy thread 增加. 所以根本原因是CPU 过高.

为什么CPU 很高?

要看CPU都花在哪里了, 所以要对应用做 profiling, 于是使用 async-profiler 做 profiling. 结果如下图:
flame.png

从图上看到, 应该是新建连接太多, 每个都要SSL, 所以耗时, 变慢.

为啥连接这么多

最近流量增加, 每个都要调用下游, 但是没有使用连接池, 导致每个下游调用都需要新建一个SSL连接.

诊断错误的正则表达式导致的 StackOverflowError

在生产环境中, 见过很多错误的正则表达式导致的各种错误, 比如: 导致CPU 100%(完整占用一个CPU), 导致StackOverflowError, 导致性能下降很严重. 这里以一个错误的正则表达式导致StackOverflowError 为例子, 看如何诊断并找到根本原因.

案例

我们看到某个应用的错误最近非常多的 StackOverflowError, 并且CPU 使用率明显上升, 下面是我们在错误日志看到的:

st=java.lang.StackOverflowError 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4340) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$GroupHead.match(Pattern.java:4807) 
at java.base/java.util.regex.Pattern$Loop.match(Pattern.java:4944) 
at java.base/java.util.regex.Pattern$GroupTail.match(Pattern.java:4866) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$GroupHead.match(Pattern.java:4807) 
at java.base/java.util.regex.Pattern$Loop.match(Pattern.java:4944) 
at java.base/java.util.regex.Pattern$GroupTail.match(Pattern.java:4866) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347) 
at java.base/java.util.regex.Pattern$BmpCharPropertyGreedy.match(Pattern.java:4347)
...

这是截取的部份在日志中的栈, 但是即便日志中的栈有 1024 行, 但是这1024行都是上面最后面5行的重复. 也就是说尽管抛出 StackOverflowError 错误的时候, 打印出最后的1024行栈, 但是这1024 行都是正则表达式处理的栈, 没有我们期望看到的到底是我们的哪段代码调用了这个正则.

问题分析

要想找到这个错误的正则表达式, 我们必须找到我们自己的代码, 如果我是代码开发者, 并且这个应用仅有一处使用正则表达式的地方, 那么很容易就能找到这个正则表达式. 如果做为SRE, 对这个应用代码不熟悉, 可能就要想其它方法了.

如果找到了这个正则表达式, 除非这个正则表达式的错误看起来很明显, 否则, 我们都需要一个被处理的字符串样本, 如何拿到一个样本?

StackOverflowError 栈的深度

  1. 多深的栈会发生 StackOverflowError?
  2. 发生 StackOverflowError 时, 会打印最内层多少层栈?
    我们可以通过命令来看一下(当前JDK是 MAC 上 JDK17):

    java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep Stack
      intx CompilerThreadStackSize                  = 1024                                   {pd product} {default}
      intx MaxJavaStackTraceDepth                   = 1024                                      {product} {default}
      bool OmitStackTraceInFastThrow                = true                                      {product} {default}
      bool StackTraceInThrowable                    = true                                      {product} {default}
      intx ThreadStackSize                          = 1024                                   {pd product} {default}
      intx VMThreadStackSize                        = 1024                                   {pd product} {default}
    openjdk version "17.0.4.1" 2022-08-12 LTS

上面各个选项的说明:

  1. CompilerThreadStackSize JVM 编译器的栈大小, 单位是 Kbytes, 所以是栈的大小, 不是深度.
  2. MaxJavaStackTraceDepth 当有异常的时候, 打印的Java 栈的深度的行数, 0 表示所有行, 默认1024行.
  3. OmitStackTraceInFastThrow 如果一个异常抛出太快太多, 那就省略它的栈, 直接打印异常名字. 一般开始还打印栈, 后面就直接打印异常名字了(因为太热, 编译成二进制). 笔者在生产环境见过多次这样的问题, 都是到最开始出错的地方去看原始栈, 或重启它.
  4. StackTraceInThrowable 在 Throwable 抛出时, 带着栈.
  5. ThreadStackSize 线程栈的大小, 单位是 Kbytes, 所以不是深度.
  6. VMThreadStackSize 非 Java 栈的大小, 单位是 Kbytes, 所以不是深度.

所以, 我们可以回答上面的2个问题: 能运行栈的深度是由 ThreadStackSize 决定的, 它的单位是字节, 在上面的 JVM 中, 它最大允许 1024 KB, 而出异常后能打印的栈, 是由 MaxJavaStackTraceDepth 决定的, 它默认只打1024行.

如何找到错误使用正则表达式的代码以及错误的正则表达式?

通过设置 -XX:MaxJavaStackTraceDepth=0 就能打印出所有栈, 那么就能逐级向上找到错误正则.

如何找到一个导致 StackOverflowError 的样本?

在 上面我们自己的代码处 加上 try {} catch (StackOverflowError e) {}, 当捕获着异常的时候, 打印导致出错的样本.