JVM 君,你过分了

共 10317字,需浏览 21分钟

 ·

2021-07-12 21:40

JVM 对 Java 有多重要,对程序员面试有多重要,这些不用多说。

如果你还没意识到学 JVM 的必要性,或者不知道怎么学 JVM,那么看完这篇文章,你就能知道答案了。

曾经的我很不屑于学 JVM,但是后来发现不学不行。这就像和媳妇吵架之后我不想道歉一样,不道歉那是不可能的,道歉是早晚的事儿,逃不掉。

后来我明白了:

认怂越晚,结局越惨。

但是我学的时候才知道:JVM,你太过分了,太难学了!

我的学习过程可以说非常坎坷,不过经历坎坷之后,我倒是发现学 JVM 的门道很多。

以我的经验加上和同行们的交流,我认为学 JVM 最好的方法是:

在程序员不同的水平段,做精准的学习。

所谓的精准学习,就是学习对自己工作有巨大帮助的知识点。以工作内容带动学习,等到积累多了,再一举攻克所有 JVM 知识点,最终熟练掌握 JVM 底层原理。

下面我来说说初级、高级、资深程序员,如何循序渐进、分步学习。

初级程序员怎么学

对刚入行的新手程序员,工作一般是修复简单 bug、开发简单功能。如何编码少出 bug,是这个阶段的核心问题。

对于这个核心问题,JVM 原理必须深入掌握两个知识点。

1. 类的初始化

类的初始化,要了解的非常深入才可以。否则,一不留神就会往项目里引入一些有关初始化的 bug。

比如看看下面这段代码:

public class ParentClass {
    private int parentX;
    public ParentClass() {
        setX(100);
    }
    public void setX(int x) {
        parentX = x;
    }
}

public class ChildClass extends ParentClass{
    private int childX = 1;
    public ChildClass() {}
    @Override
    public void setX(int x) {
        super.setX(x);
        childX = x;
        System.out.println("ChildX 被赋值为 " + x);
    }
    public void printX() {
        System.out.println("ChildX = " + childX);
    }

}

public class TryInitMain {
    public static void main(String[] args) {
        ChildClass cc = new ChildClass();
        cc.printX();
    }
}

有兴趣可以运行看看结果,一旦把这种代码放到了生产环境里,排查非常困难。

2. Java 内存结构和对象分配

第二个知识点,就是 Java 内存结构和对象分配的基础知识,尤其是 JVM 内存中堆的布局和对象分配的关系。

比如,堆内存的布局

当然,Java7 后,新布局变了

知道布局了,就得知道java对象分配的基本原则:

  • 对象优先在Eden区分配
  • 对象太大直接会分配到老年代

只有知道这些知识,才不会经常写下底下这种 bug:

// 将全部行数读取的内存中 
List<String> lines = FileUtils.readLines(new File("temp/test.txt"), Charset.defaultCharset()); 
for (String line : lines) { 
    // pass 

上面这段代码,一旦读取到了大文件,很可能把生产环境搞崩。

所以,把上述两个知识点深入理解了,对新手提升自己的代码质量非常非常有用。只有代码质量上去了,你才能得到更好的发展。

对于这两个知识点,我认为通过网络的文章去学习最好。如果直接看书,有两个最大的缺点:

  • 知识积累不足导致学不懂
  • 书中冗余知识点太多,互相交杂,精力耗费过大,性价比不高

故这里学习推荐根据知识点去搜文章读,而不是找原理性的书籍看。

高级程序员怎么学

对处于这个阶段的朋友,他们已经可以熟练编写健壮的代码了,经常会独立开发出一个大的功能模块,有的可能还能独立开发出一个完整的小型项目。

这时候,他们可能会面临两种情况:

1. 需要写一些工具类给全团队使用

在这种情况下,你很可能就需要 Java 中的语法糖,因为语法糖能让你写出非常灵活简单的代码。这包括泛型,自动拆装箱,可变参数还有遍历循环。

但是,使用这些语法糖的时候,如果你不熟悉他们在 JVM 中的实现原理,就非常容易栽个大跟头,

比如:

public class GenericPitfall {
    public static void main(String[] args) {
      List list = new ArrayList();
      list.add("123");
      List<Integer>  list2 = list;
      System.out.println(list2.get(0).intValue());
  }
}

2. 编写性能优越的代码

什么时候需要性能优越的代码?最常见的就是把以前性能不好的同步实现,转化成异步实现。

而这种要求,就需要开发对 Java 的多线程开发非常熟悉,并且一定要深入理解多线程在 JVM 中的原理实现。

不然,可以看看下面这段代码:

class IncompletedSynchronization {
  int x;

  public int getX() {
      return x;
  }

  public synchronized void setX(int x) {
      this.x = x;
  }
}

再看看这段:

Object lock = new Object();
synchronized (lock) {
  lock = new Object();
}

如果把上面这些代码上了生产环境,熬通宵排查问题的命运就注定了……

这里的知识点,我推荐通过网上的文章看,又因为涉及到了并发知识,我建议就着《Java Performance》第二版的“Chapter 9. Threading and Synchronization Performance”这章一起看。

还有余力,建议再继续看周志明的那本《深入理解 JAVA 虚拟机》第三版中的 12-13 章。周志明这本书讲的十分深入,也带来个缺点:门槛高。此时,如果没看懂可以放一放。

注意,我这里说的是并发的原理,不是并发实践,读者想学并发编程,《JAVA 并发编程实践》我认为是前提条件,故不会赘述。

资深程序员怎么学

这时候的你,已经开始承担项目开发中很重要的职责了,有些出色的朋友都开始带团队了。那这时候,你可能会做下面的事:

1. 合理规划项目使用资源

合理规划项目使用资源,前提是对垃圾回收有非常深入的了解。

如果说在新手期,已经对 Java 对象的内存分配和内存使用有了大致的概念,那么,这个垃圾回收,则是这类知识的进一步拓展。

只有理解了各种垃圾回收的原理,再配合着 Java 内存布局的基础知识,才能更好地规划出项目用什么回收算法,才能在合适的资源利用度上得到最佳性能。

比如,新生代和老年代之间的合适比例。比如,新生代中 Eden 和 Survivor 区域间的比例。

2. 排查各种线上问题

要排查各种问题,就需要对 JVM 提供的各种故障排查工具非常了解。

这些工具又分为两类:

  • 基础的命令行形式的故障处理工具,比如 jps、jstack 等等
  • 第二类是可视化的故障处理工具,比如 VisualVM

但是,掌握工具的使用还不够。因为有关垃圾回收的问题,还必须得通过解析 GC 日志后,再通过工具的使用,才可能能定位到问题的根源。

所以,最好对使用故障排查工具和 GC 日志都非常熟练。

比如:

2021-05-26T14:45:37.987-0200: 151.126:
[GC (Allocation Failure) 151.126: [DefNew: 629119K->69888K(629120K), 0.0584157 secs] 1619346K->1273247K(2027264K), 0.0585007 secs]
[Times: user=0.06 sys=0.00, real=0.06 secs]

2021-05-26T14:45:59.690-0200: 172.829:
[GC (Allocation Failure) 172.829: [DefNew: 629120K->629120K(629120K), 0.0000372 secs]172.829: [Tenured: 1203359K->755802K(1398144K), 0.1855567 secs] 1832479K->755802K(2027264K), [Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs]
[Times: user=0.18 sys=0.00, real=0.18 secs]

上面这条,应该一眼看出来,垃圾算法用的是 Serial 收集器,并且年轻代分配出现了问题,大小可能需要调整。

这里的知识点,强烈反对看网上的文章,网上说的很多细节有问题,疏漏很多。所以,推荐看书。

《Java Performance》第二版里,“Chapter 5. An Introduction to Garbage Collection”,“Chapter 6. Garbage Collection Algorithms”的知识已经足够。

有人去看《深入理解 JAVA 虚拟机》第三版中的第 3 章,讲垃圾收集器与内存分配策略的。这里还是老问题,讲的太细,我建议绕过 3.4 节,讲 HotSpot 算法细节的那块儿。

这里安全点这个知识点挺重要,但是现在这个阶段想理解挺难的。我觉得将来做一些底层框架,接触到崩溃恢复的 checkpoint 相关思想了,再回头来学习,那才能真正理解和掌握。

技术专家怎么学

达到这个级别了,那就需要对整套 JVM 要有非常深入的了解了,因为你是解决技术问题的最后保障了。有些时候,甚至还需要因为某些问题开发出各种各样的工具。

曾经,有个项目时不时总是会报错:

java.lang.OutOfMemoryError: GC overhead limit exceeded

这个问题几个同事都没搞定,就来找我。我看了看,突然想起来,以前在官方调优指南《HotSpot Virtual Machine Garbage Collection Tuning Guide》看到过相关介绍。

JVM 本身内存不足就会运行 GC,但是如果每次 GC 回收的内存不够,那么很快就会开始下一次 GC。

JVM 有个默认的保护机制,如果发现在一个统计周期内,98% 的时间都是在运行 GC,内存回收却少于 2% 的时候,就会报这个错。

怎么引起的呢?这个问题如果去排查代码,那真的是难如登天,首先,没有任何堆栈错误去帮助定位问题。其次,项目代码量大了去了,而且是年头久远。

这时,就需要通过对 JVM 总体的深入理解,去反推问题了。我当时是这样推理的:

内存溢出,GC 无法回收问题,说明了两个问题:

  1. 堆内的内存不够用了
  2. 占用内存的对象要么就是该关闭的资源没有关闭,要么被大量的暂时放在一起了

那如果我 dump 出内存文件出来,再分析下就知道是哪些对象在占用内存了。

一查发现是大量的字符串在占用内存。

综合我前面的推测,字符串不是数据库连接,肯定没有该关闭未关闭的问题。那就剩一个可能了,就是被大量的暂时放起来了,导致 GC 回收不了。

那么新问题来了,能大量放字符串的,会是什么?

首先就去猜缓存。根据这条线索,直接去源码搜 Cache 关键词,把所有关于 Cache 的代码都看了下。一下子就找到问题了。

原来,我们有个功能是解析一个非常大的文件。文件的格式如下:

需要把这个文件的每一行内容按照列去一起存到数据库里。

由于写代码的人偷懒,想一次解析完毕后一股脑全塞到数据库里。所以,他弄了个 Map,Map 的 Key 是相同的列名,Value是每一行解析过的内容。

而这样写代码的结果就是,一行对应了一个有三个条目的 HashMap。如果文件有十几万行,就有十几万的 HashMap。然后,这些 HashMap 再存到一个列表里,再把这个列表放到一个叫做 xxxCache 的 HashMap 中。

示意代码如下:

public class ParseFile4OOM {
    public static void main(String[] args) {
        List<Map<String, String>> lst = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            Map<String, String> map = new HashMap<>();
            map.put("Column1""Content1");
            map.put("Column2""Content2");
            map.put("Column3""Content3");
            lst.add(map);
        }

        Map<String, List<Map<String, String>>> contentCache = new HashMap<>();
        contentCache.put("contents", lst);
    }
}

那对这种情况怎么办呢?代码还不能大动,只能优化。

那时,我们已经用了 JDK8 了,引入了 String 常量池。同时,Hashmap 在这个业务场景下,容积是固定的,所以,就不应该给它多分配空间,就固定死为 3。

优化后,代码如下:

public class ParseFile4OOM {
    public static void main(String[] args) {
        List<Map<String, String>> lst = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            Map<String, String> map = new HashMap<>(3);
            map.put("Column1".intern(), "Content1".intern());
            map.put("Column2".intern(), "Content2".intern());
            map.put("Column3".intern(), "Content3".intern());
            lst.add(map);
        }

        Map<String, List<Map<String, String>>> contentCache = new HashMap<>();
        contentCache.put("contents".intern(), lst);
    }
}

把优化后的代码上线,错误搞定了!

所以,在这个阶段就非得把 JVM 吃透不可了。吃透原理就必须靠看书了。

周志明的《深入理解 JAVA 虚拟机》是必须的了,但是还不够。

《Oracle JRockit: The Definitive Guide》这本书我也建议读一读,虽然老了,但是里面的很多内容,尤其前四章,对 JVM 原理真的快讲透了。对 JVM 是如何弹性伸缩去平衡资源和性能关系的,娓娓道来,让我醍醐灌顶,编程视野一下子打开了很多。

至此,不同阶段的学习方法讲完了。

总的来说,JVM 知识广博复杂,如果想要掌握,不能一蹴而就。而且咱们程序员不容易,需要学的知识太多,然而咱们的精力却是有限的。

所以,对于 JVM 原理来说,假设有些知识点眼前看不懂,用不上,可以先暂时放一放,做到精准学习,把省下来的精力用在别的知识甚至自己的生活上,更有意义。

看完如果觉得有收获,希望能帮忙转发、随手点个在看,你的支持对我很重要


你好,我是四猿外。

一家上市公司的技术总监,管理的技术团队一百余人。

我从一名非计算机专业的毕业生,转行到程序员,一路打拼,一路成长。

我会通过公众号,
把自己的成长故事写成文章,
把枯燥的技术文章写成故事。

我建了一个读者交流群,里面大部分是程序员,一起聊技术、工作、八卦。欢迎加我微信,拉你入群。

推荐阅读

没有大厂的命,得了大厂的病

我招了个“水货”程序员

浏览 39
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报