本篇文章的标题很怪,我来解释一下。
这是我自己的一种体会,就是很多时候了解一个原理的时候,我就总想看见它。
研究 CPU 原理的时候,我想看见一条条指令执行时 CPU 里的电信号究竟是怎么走的。
研究操作系统原理的时候,我想看见从计算机启动到操作系统加载,内存布局实实在在发生了哪些变化。
同样研究 Java 原理的时候,我想看见每一条 Java 代码最终翻译成了什么机器指令在执行,我想看见一个 Java 对象在内存中究竟长什么样子,等等等等...
总之,我就想看见!
如果你也时时刻刻有这种纠结,并且不希望只停留在 Java 语言层面和网上一些博客所 "讲述" 出来的原理上,今天我就给你分享一些 看见 Java 的小技巧,也让你能从不同层级来分析一个原理。
- javap:看见字节码
- strace:看见系统调用
- hsdis+ jitwatch:看见机器码
- openJDK:看见 native 方法
- JOL:看见对象
- fastthread:看见线程
- JProfile:看见运行时的各种状态
javap:看见字节码
这个命令大家应该很熟悉,接触了一段时间的 Java 后总会不满足于语言层面的原理了解,这时候就需要通过字节码来更深入地掌握一些底层原理了。
此时 javap
命令就很关键,它其实是解析整个的 .class 文件,但我们通常用它来分析里面的字节码指令。
命令的使用方法大家自己网上搜吧,这里提供一个平时研究字节码最友好的方式,就是使用 IDEA 的插件,你可以叫他字节码学习神器 jclasslib。
下载好插件后,你的 view 菜单会多出这么一项
点击它就可以分析当前类的字节码了
这一步,跨越了了解 Java 底层原理的第一层,下面接着往底层走~
strace:看见系统调用
当吃透了从 JVM 层级了解原理后,可能又感觉不够味道了,想看看 JVM 底层是怎么实现的。但又不想直接深入到最终的机器指令来看,那其实还有一个很好的中间层就是系统调用啦。
如果不了解系统调用可以先复习下操作系统相关知识。
strace
命令就可以查看某一个程序运行过程中,发起了哪些系统调用,并且这个过程是实时的。
下面我们用这个命令,看一下 Java 中传统的 BIO 服务端程序,会走哪些系统调用,并从这个层面对 BIO 的原理进行简单剖析,你会发现这种视角对传统视角来说有点降维打击了。
看一个传统 BIO 的流程
第一步:写一个贼简单的 socket 服务端程序,开放 8080 端口并监听
public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(8080); Socket socket = serverSocket.accept(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream())); System.out.println(bufferedReader.readLine());}
第二步:用 strace 命令查看这个程序启动后的系统调用(这里我们只看网络相关的系统调用)
[bash ~]# strace -ff -e trace=network java SocketDemo
...[pid 28226] socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5...[pid 28226] bind(5, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0[pid 28226] listen(5, 50) = 0
可以看到,实际上服务端的 socket 程序,向 linux 操作系统发起了三个系统调用 socket
、bind
、listen
,然后就不往下走了。这时 Java 进程并没有结束,说明产生了一个阻塞(具体就是阻塞在 Java 代码的那句 accept 上)
第三步:用 nc 命令连接一下 8080 端口
[bash ~]# nc localhost 8080
...[pid 28226] socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5...[pid 28226] bind(5, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0[pid 28226] listen(5, 50) = 0[pid 28226] accept(5, {sa_family=AF_INET6, sin6_port=htons(11103), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 6[pid 28226] recvfrom(6,
可以看到用 nc 命令与 socket 服务端建立 TCP 连接后,系统调用多了 accept
和recvfrom
,又不再往下走了,而此时 Java 进程仍然没有结束,说明又发生了阻塞(这次是阻塞在了 Java 代码的那句 readline,对应系统调用时 recvfrom)
第四步:继续在刚刚的 nc 命令中,传入一个字符串 "hello",敲回车发送
[bash ~]# nc localhost 8080
hello
...[pid 28226] socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5...[pid 28226] bind(5, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0[pid 28226] listen(5, 50) = 0[pid 28226] accept(5, {sa_family=AF_INET6, sin6_port=htons(11103), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 6[pid 28226] recvfrom(6, "hello\n", 8192, 0, NULL, NULL) = 6hello+++ exited with 0 +++
可以看到刚刚的 recvfrom 函数补全了,有了返回值,并且整个程序也结束了。
通过 strace
这个命令,我们从更底层的角度全面了解了 Java 中 BIO 的流程,如果你同样也了解操作系统并且熟悉 linux 下的各种系统调用的细节,那可以说这么一个小实验就帮你几乎完全掌握了 BIO 的原理。同样 NIO 也是如此,这样是不是就比单从 Java 层面了解 BIO NIO 这些知识点形成了降维打击呢?
当然,很多方法不走系统调用,如果核心原理不是系统调用组成的话,就不适合用这种方法研究了,那下面介绍一种终极办法,如果你能肉眼读汇编的话,你通过下面这个方式"理论上"可以了解所有原理。
HSDIS + JITWATCH:看见机器码(汇编)
这个可谓是最降维打击的一种方式了,可以直接看到 Java 代码最终在 CPU 层级上是执行了哪些机器指令。
通过这个文章可以很好地搭建这个环境:
https://zhuanlan.zhihu.com/p/158168592?from_voters_page=true
最终实现的效果就是如下
当然这个方法有点过于深入了,对于现在的程序和复杂的 JVM 而言,不可能通过看机器码去了解全貌的,只能说想要特别特别深入地去扣某一行代码最本质的执行细节的时候,知道有这种方式就行了。
如果想了解最底层的实现,其实不用非要观察正在执行的机器码,因为我们有源码呀。比如 native 方法,或者 sychronized 关键字的虚拟机实现原理,下面这个方式就适合你了。
openJDK:看见 native 方法
读 jdk 源码,有的时候走到了 native 方法,往往很绝望,因为代表着自己跟了这么久的努力白费了。
public class Object { ... public native int hashCode(); ...}
如果真的还想了解 native 方法的底层实现,其实下载 openJDK 的源码即可:https://github.com/openjdk/jdk
有了源码后首先进入 ./jdk/src/share/native
,可以看到如下目录结构
com
common
java
sun
再进入 java 目录后
io
lang
net
nio
security
util
有没有发现和 jdk 目录基本一样?那基本就知道怎么查了吧。
比如我想查找 Object 类的 hashcode 方法,我就可以根据 Object 在 jdk 中的目录,找到其 native 方法的目录
./jdk/src/share/native/java/lang/Object.c
...#include "java_lang_Object.h"static JNINativeMethod methods[] = { {"hashCode", "()I", (void *)&JVM_IHashCode}, {"wait", "(J)V", (void *)&JVM_MonitorWait}, {"notify", "()V", (void *)&JVM_MonitorNotify}, {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll}, {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},};...
再往下跟,九曲十八弯之后,会跟到这个方法 openjdk\hotspot\src\share\vm\runtime\synchronizer.cpp --> FastHashCode
,以下代码省略十万行...
intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) { ... hash = mark->hash(); if (hash == 0) { hash = get_next_hash(Self, obj); temp = mark->copy_set_hash(hash); // merge hash code into header ... } ... // We finally get the hash return hash;
native 方法中 C++ 代码很多,所以如果真的想通过自己阅读源码了解 native 底层实现,可以学习一下 c++ 的基本语法。
当然 openJDK 还可以看虚拟机的一些实现原理,比如看看 sychronized
关键字在虚拟机层面的实现,这里就不展开了。
JOL:看见对象
面试总是问一个对象在内存中的布局,能不能有一种直观的方法,可以直接看到某个对象的内存布局呢?并且我可以不断地做实验,来观察一个对象内存中数据的变化。
当然有,而且还是官方的呢,就是 openJDK 官方提供的工具 JOL
使用起来十分简单,只需要 maven 引入即可
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>put-the-version-here</version></dependency>
然后代码中可以直接调用 ClassLayout.parseInstance(o).toPrintable()
,如下
public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable());}
看控制台输出,可以直接看到对象头的二进制表示。由于 Object 没有成员变量,所以成员变量就没有体现出来。
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
是不是非常直观?由于锁的信息正是存储在对象头中,所以我们可以用这个工具,看一下锁升级的过程。这样自己亲自操作一遍之后,锁升级就也变得直观起来了。
看下锁升级的过程
因为锁的信息是记录在对象头的 Mark Word 里,所以通过自己做实验,打印出不同阶段的对象信息,可以 看见 锁升级的过程。
这里我写了一段代码,有点长就不粘贴过来了,粘了估计你也不会点开看嘿嘿~
我只把输出结果打出来,信息太多,只取了表示锁类型的信息
- 刚启动 new:
001
(无锁) - 等五秒 new:
101
(匿名偏向锁) - 主线程加了一次锁(:
101
偏向锁) - 主线程释放了锁:
101
偏向锁) - 一个新线程加锁:
00
(轻量级锁) - 新线程释放锁、结束:
001
(无锁) - 两个线程抢锁:
10
(重量级锁) - 两个线程都结束:
10
(重量级锁) - 等一秒:
001
(无锁)
fastthread:看见线程
我们知道用 jstack
命令可以将当前 JVM 的线程快照 dump 成一个文件。
但这个文件不够直观,一堆文本,也有很多工具可以将其可视化,不过我用的最顺手也最漂亮的可视化工具,就是 fastthread 了,更方便的是它是个网页。
反正我第一次看这个网站时,是被它美到了。官网上看就非常的科技范,很带感,让人很想上传一个 dump 文件分析分析。
我上传了一个用 jstack 生成的 dump 文件,经过一段时间分析后,可以看到一个非常漂亮的页面
查看线程整体情况
查看线程分组情况
查看线程细节
还有很多,就不一一演示了,官网非常友好,大家可以前去逛逛~
JProfile:看见运行时的各种状态
看见 JVM 运行时的实时状态,非常有利于我们从系统层面了解程序的运行过程,不知道你有没有用过 jdk 自带的可视化工具 JConsole
有点丑,而且功能不是很强大,于是就有了 JProfile
JProfiler 是由 ej-technologies GmbH 公司开发的一款性能瓶颈分析工具,说白了就是 JConsole 的高级美化版,我们可以先来欣赏下它的外观。
这里我们用这样一段程序来跑,可以预想到这个程序不断占用堆内存并且无法被 gc,最后 OOM
public static void main(String[] args) throws Exception { List<Byte[]> list = new ArrayList<>(); while (true) { list.add(new Byte[1024*1024]); Thread.sleep(100); }}
打开 JProfile 综合页面,实时跟进一些性能信息
最后 Java 程序也果然报出了 OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
我们到对象监控面板可以查看到所有的对象信息,并且按照指定规则排序
当然针对本个实例,我们直接可以用里面的大对象分析视图来看(Biggest Object)
这样一下就找出了占内存空间最大的就是我们不断增长的 ArrayList 对象。
当然我们也可以用它分析线程,但一般分析某一瞬间的快照,还是到 fastthread 网站上更为直观,功能更强大一些。
JProfile 也可以通过插件,直接与 IDEA 相连,最终效果就是在 IDEA 里点击用 JProfile 去 run,就自动跳转到 JProfile 的监控程序里啦。
再说一分钟
通过 javap
查看字节码,通过 strace
和 HSDIS
查看与计算机更底层的交互,再通过阅读 openJDK
的源码了解 native 方法和 JVM 实现,再手握一本 Java 语言规范
和 虚拟机规范
的官方文档,理论上说你可以自己摸索出 Java 这门语言的全部原理了。所以平时通过这些小工具自己多做一些实验,还是很有帮助的,你会得到一些比看博文甚至看书更为深入的理解。
原作者:闪客sun
原文链接:看见 Java
原出处: 低并发编程
侵删