点击 RoadToGrowth 即可查看原文和更多的文章,欢迎star。
JDK(Java Development Kit) 是用于开发 Java 应用程序的软件开发工具集合,包括 了 Java 运行时的环境(JRE)、解释器(Java)、编译器(javac)、Java 归档 (jar)、文档生成器(Javadoc)等工具。简单的说我们要开发Java程序,就需要安装某个版本的JDK工具包。
JRE(Java Runtime Enviroment )提供 Java 应用程序执行时所需的环境,由 Java 虚拟机(JVM)、核心类、支持文件等组成。简单的说,我们要是想在某个机器上运 行Java程序,可以安装JDK,也可以只安装JRE,后者体积比较小。
Java Virtual Machine(Java 虚拟机)有三层含义,分别是:
JVM规范要求
满足 JVM 规范要求的一种具体实现(一种计算机程序)
一个 JVM 运行实例,在命令提示符下编写 Java 命令以运行 Java 类时,都会创建一 个 JVM 实例,我们下面如果只记到JVM则指的是这个含义;如果我们带上了某种JVM 的名称,比如说是Zing JVM,则表示上面第二种含义
就范围来说,JDK > JRE > JVM:
Java程序的开发运行过程为:
我们利用 JDK (调用 Java API)开发Java程序,编译成字节码或者打包程序 然后可以用 JRE 则启动一个JVM实例,加载、验证、执行 Java 字节码以及依赖库, 运行Java程序。
而JVM 将程序和依赖库的Java字节码解析并变成本地代码执行,产生结果 。
最简单/最麻烦的查询方式是询问相关人员。
查找的方式很多,比如,可以使用 which , whereis , ls ‐l 跟踪软连接, 或者 find 命令全局查找(可能需要sudo权限), 例如:
没有量化就没有改进
性能优化一般要存在瓶颈问题,而瓶颈问题都遵循80/20原则。既我们把所有的整个处理过程中比较慢的因素都列一个清单,并按照对性能的影响排序,那么前20%的瓶颈问题,至少会对性能的影响占到80%比重。换句话说,我们优先解决了最重要的几个问题,那么性能就能好一大半。
我们一般先排查基础资源是否成为瓶颈。看资源够不够,只要成本允许,加配置可能是最快速的解决方案,还可能是最划算,最有效的解决方案。 与JVM有关的系统资源,主要是 CPU 和 内存 这两部分。 如果发生资源告警/不足, 就需要评估系统容量,分析原因。
一般衡量系统性能的维度有3个:
性能指标还可分为两类:
性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 TPS和QPS, 就是极限值。知道了极限值,我们就可以按业务发展测算流量和系统压力,以此做容量规划,准备机器资源和预期的扩容计划。最后在系统的日常运行过程中,持续观察,逐步重做和调整以上步骤,长期改善改进系统性能。
我们经常说“ 脱离场景谈性能都是耍流氓 ”,实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到3000TPS如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到3100TPS就没有什么意义,同样地如果花一倍成本去优化到5000TPS 也没有意义。
Donald Knuth曾说过“ 过早的优化是万恶之源 ”,我们需要考虑在恰当的时机去优化系统。在业务发展的早期,量不大,性能没那么重要。我们做一个新系统,先考虑整体设计是不是OK,功能实现是不是OK,然后基本的功能都做得差不多的时候(当然整体的框架是不是满足性能基准,可能需要在做项目的准备阶段就通过POC(概念证明)阶段验证。),最后再考虑性能的优化工作。因为如果一开始就考虑优化,就可 能要想太多导致过度设计了。而且主体框架和功能完成之前,可能会有比较大的改动,一旦提前做了优化,可能这些改动导致原来的优化都失效了,又要重新优化,多做了很多无用功。
首先,我们可以把形形色色的编程从底向上划分为最基本的三大类:机器语言、汇编 语言、高级语言。
按《计算机编程语言的发展与应用》一文里的定义:计算机编程语言能够实现人与机器之间的交流和沟通,而计算机编程语言主要包括汇编语言、机器语言以及高级语言,具体内容如下:
如果按照有没有虚拟机来划分,高级编程语言可分为两类:
有虚拟机:Java,Lua,Ruby,部分JavaScript的实现等等
无虚拟机:C,C++,C#,Golang,以及大部分常见的编程语言
如果按照变量是不是有确定的类型,还是类型可以随意变化来划分,高级编程语言可 以分为:
静态类型:Java,C,C++等等
动态类型:所有脚本类型的语言
如果按照是编译执行,还是解释执行,可以分为:
编译执行:C,C++,Golang,Rust,C#,Java,Scala,Clojure,Kotlin, Swift…等等
解释执行:JavaScript的部分实现和NodeJS,Python,Perl,Ruby…等等
此外,我们还可以按照语言特点分类:
面向过程:C,Basic,Pascal,Fortran等等
面向对象:C++,Java,Ruby,Smalltalk等等
函数式编程:LISP、Haskell、Erlang、OCaml、Clojure、F#等等
有的甚至可以划分为纯面向对象语言,例如Ruby,所有的东西都是对象(Java不是所有东西都是对象,比如基本类型 int 、 long 等等,就不是对象,但是它们的包装 类 Integer 、 Long 则是对象)。 还有既可以当做编译语言又可以当做脚本语言的,例如Groovy等语言。
现在我们聊聊跨平台,为什么要跨平台,因为我们希望所编写的代码和程序,在源代 码级别或者编译后,可以运行在多种不同的系统平台上,而不需要为了各个平台的不 同点而去实现两套代码。典型地,我们编写一个web程序,自然希望可以把它部署到 Windows平台上,也可以部署到Linux平台上,甚至是MacOS系统上。 这就是跨平台的能力,极大地节省了开发和维护成本,赢得了商业市场上的一致好评。
这样来看,一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。
1、典型的源码跨平台(C++):
2、典型的二进制跨平台(Java字节码):
可以看到,C++里我们需要把一份源码,在不同平台上分别编译,生成这个平台相关的二进制可执行文件,然后才能在相应的平台上运行。 这样就需要在各个平台都有开发工具和编译器,而且在各个平台所依赖的开发库都需要是一致或兼容的。 这一点在过去的年代里非常痛苦,被戏称为 “依赖地狱”。 C++的口号是“一次编写,到处(不同平台)编译”,但实际情况上是一编译就报错,变 成了 “一次编写,到处调试,到处找依赖、改配置”。 大家可以想象,你编译一份代 码,发现缺了几十个依赖,到处找还找不到,或者找到了又跟本地已有的版本不兼 容,这是一件怎样令人绝望的事情。
而Java语言通过虚拟机技术率先解决了这个难题。 源码只需要编译一次,然后把编译 后的class文件或jar包,部署到不同平台,就可以直接通过安装在这些系统中的JVM上 面执行。 同时可以把依赖库(jar文件)一起复制到目标机器,慢慢地又有了可以在各个平台都直接使用的Maven中央库(类似于linux里的yum或aptget源,macos里的 homebrew,现代的各种编程语言一般都有了这种包依赖管理机制:python的pip, dotnet的nuget,NodeJS的npm,golang的dep,rust的cargo等等)。这样就实现了 让同一个应用程序在不同的平台上直接运行的能力。
总结一下跨平台:
我们前面提到了很多次 Java运行时 和 JVM虚拟机 ,简单的说JRE就是Java的运行 时,包括虚拟机和相关的库等资源。 可以说运行时提供了程序运行的基本环境,JVM在启动时需要加载所有运行时的核心库等资源,然后再加载我们的应用程序字节码,才能让应用程序字节码运行在JVM这 个容器里。
但也有一些语言是没有虚拟机的,编译打包时就把依赖的核心库和其他特性支持,一 起静态打包或动态链接到程序中,比如Golang和Rust,C#等。 这样运行时就和程序指令组合在一起,成为了一个完整的应用程序,好处就是不需要虚拟机环境,坏处是编译后的二进制文件没法直接跨平台了。
内存管理就是内存的生命周期管理,包括内存的申请、压缩、回收等操作。 Java的内存管理就是GC,JVM的GC模块不仅管理内存的回收,也负责内存的分配和压缩整理。
Java中的字节码,英文名为 bytecode , 是Java代码编译后的中间代码格式。JVM需要读取并解析字节码才能执行相应的任务。 由单字节( byte )的指令组成, 理论上最多支持 256 个操作码(opcode)。实际上Java只使用了200左右的操作码, 还有一些操作码则保留给调试操作。
操作码, 下面称为指令 , 主要由类型前缀和操作名称两部分组成。
例如,’ i ’ 前缀代表 ‘ integer ’,所以,’ iadd ’ 很容易理解, 表示对整数执行加法运算。
此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等
我们都知道 new 是Java编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new 。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:
```
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "":()V
```
当你同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象! 为什么是三条指令而不是一条呢?这是因为:
在调用构造函数的时候,其实还会执行另一个类似的方法 ,甚至在执行构造函数之前就执行了。还有一个可能执行的方法是该类的静态初始化方法 ,但 并不能被直接调用,而是由这些指令触发的: new , getstatic , putstatic or invokestatic。
有很多指令可以操作方法栈。 前面也提到过一些基本的栈操作指令: 他们将值压入栈,或者从栈中获取值。 除了这些基础操作之外也还有一些指令可以操作栈内存; 比如 swap 指令用来交换栈顶两个元素的值。下面是一些示例:
最基础的是 dup 和 pop 指令。
还有复杂一点的指令:比如, swap , dup_x1 和 dup2_x1 。
dup , dup_x1 , dup2_x1 指令补充说明 :
Java字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型( int , long , double , float ),都有加, 减,乘,除,取反的指令。 那么 byte 和 char , boolean 呢? JVM 是当做 int 来处理的。另外还有部分指令用于数据类型之间的转换。
当我们想将 int 类型的值赋值给 long 类型的变量时,就会发生类型转换。
那么 invokevirtual 和 invokeinterface 有什么区别呢?这确实是个好问 题。 为什么需要 invokevirtual 和 invokeinterface 这两种指令呢? 毕竟 所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了吗? 这么做是源于对方法调用的优化。JVM必须先解析该方法,然后才能调用它
一个类在JVM里的生命周期有7个阶段,分别是加载(Loading)、验证 (Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)、卸载(Unloading)。 其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就分 别来说一下这五个过程。
加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的class完全限定名, 来获取二进制classfile格式的字节流,简单点说就是 找到文件系统中/jar包中/或存在于任何地方的“ class文件 ”。 如果找不到二进制表示形式,则会抛出NoClassDefFound 错误。装载阶段并不会检查 classfile 的语法和格式。类加载的整个过程主要由JVM和Java 的类加载系统共同完成, 当然具体到loading 阶 段则是由JVM与具体的某一个类加载器(java.lang.classLoader)协作完成的。
链接过程的第一个阶段是校验 ,确保class文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。校验过程检classfile 的语义,判断常量池中的符号,并执行类型检查, 主要目的是判断字节码的合法性,比如 magic number, 对版本号进行验证。 这些检查 过程中可能会抛出 VerifyError , ClassFormatError 或 UnsupportedClassVersionError 。 因为classfile的验证属是链接阶段的一部分,所以这个过程中可能需要加载其他类, 在某个类的加载过程中,JVM必须加载其所有的超类和接口。 如果类层次结构有问题(例如,该类是自己的超类或接口,死循环了),则JVM将抛出 ClassCircularityError 。 而如果实现的接口并不是一个 interface,或者声明的超类是一个 interface,也会抛出 IncompatibleClassChangeError 。
然后进入准备阶段,这个阶段将会创建静态字段, 并将其初始化为标准默认值(比如 null 或者 0值 ),并分配方法表,即在方法区中分配这些变量所使用的内存空间。 请注意,准备阶段并未执行任何Java代码。
例如:
public static int i = 1;
在准备阶段 i 的值会被初始化为0,后面在类初始化阶段才会执行赋值为1; 但是下面如果使用final作为静态常量,某些JVM的行为就不一样了:
public static final int i = 1;
对应常量i,在准备阶段就会被赋值1,其实这样还是比较puzzle,例如其他语言 (C#)有直接的常量关键字const,让告诉编译器在编译阶段就替换成常量,类似 于宏指令,更简单。
然后进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接 口方法解析。
简单的来说就是我们编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。 在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直 接引用,那引用的目标必定在堆中存在。加载一个class时, 需要加载所有的super类和super接口。
JVM规范明确规定, 必须在类的首次“主动使用”时才能执行类初始化。 初始化的过程包括执行:
如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初 始化。所以其实在java中初始化一个类,那么必然先初始化过 java.lang.Object 类,因为所有的java类都继承自java.lang.Object。
了解了类的加载过程,我们再看看类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:
同时以下几种情况不会执行类初始化:
类加载过程可以描述为“通过一个类的全限定名a.b.c.XXClass来获取描述此类的Class 对象”,这个过程由“类加载器(ClassLoader)”来完成。这样的好处在于,子类加载器可以复用父加载器加载的类。系统自带的类加载器分为三种 :
启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++代码来实现的,并不继承自
java.lang.ClassLoader(负责加载JDK中 jre/lib/rt.jar里所有的class)。它可以看做是JVM自带的,我们再代码层面无法直接获取到
启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null 。举例来说,java.lang.String是由启动类加载器加载
的,所以 String.class.getClassLoader()就会返回null。但是后面可以看到可以通过命令行 参数影响它加载什么。
扩展类加载器(ExtClassLoader)
扩展类加载器(extensions class loader):它负责加载JRE的扩展目录,lib/ext 或者由java.ext.dirs系统属性指定的目录中的JAR包的类,代码里直接获取它的父 类加载器为null(因为无法拿到启动类加载器)。
应用类加载器(AppClassLoader)
应用类加载器(app class loader):它负责在JVM启动时加载来自Java命令的classpath或者cp选项、java.class.path系统属性指定的jar包和类路径。在应用程序代码里可以通过ClassLoader的静态方法getSystemClassLoader()来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。
类加载机制有三个特点: