文章目录
- Java基本概念
- 面向对象
- 静态类型
- 运行时
- 跨平台
- 字节码
- 编译java文件生成class文件并查看
- 分析一段代码的字节码
Java基本概念
Java 是一种面向对象
、静态类型
、编译执行
,有VM/GC
和运行时
、跨平台
的高级语言
。
面向对象
面向对象应该是在接触Java时就会谈到的一个特性,因此很多人都有自己的解释。我的理解就是,面向对象就是一种数据的封装方式,是一种编程方式。
静态类型
经常会听到静态语言和动态语言的说法。简单来说,可以这么理解:静态类型语言中,变量的类型必须先声明,即在创建的那一刻就已经确定好变量的类型,而后的使用中,你只能将这一指定类型的数据赋值给变量。如果强行将其他不相干类型的数据赋值给它,就会引发错误。而动态的编程语言则没这个问题,变量的类型不是固定的,可以自由转变。
常见的动态类型的语言有:PHP、Ruby、Python
静态类型的语言:C、C++、JAVA、C#
可以看一张对比图:
Java哪怕是用了var类型的变量,一旦给a赋值以后,它的类型就确定了,不允许后面再进行更改了。但是Pytho中的变量则没有这个问题,可以随便赋值。
运行时
什么是运行时
?
Java的运行时称之为Java Runtime
,具体 Runtime 是个什么东西呢,就是说一个程序要在一个硬件或者平台上跑,就必须要有一个中间层用来把程序语言转换为机器能听懂的机器语言。
我们写好了一段程序代码,这段代码计算机实际上是读不懂的,为了让机器能读懂并运行这段代码,就需要一个 Java 语言的运行时环境,只有在这个环境中计算机才能读懂它,计算机才能与这段代码打交道。
我们写的这几句代码,表面上只有这么几句,但实际上Java要加载很多其它的依赖才能保证这段程序代码能顺利执行,在我们写代码的时候是不会关注这些内容的,这些东西Java会在运行时在为我们加入。
简单来说,JRE(Java Runtime Environment)就是Java的运行时,JRE中包含了虚拟机和相关的库等资源。可以说运行时提供了程序运行的基本环境,JVM在启动时需要加载所有运行时的核心库等资源,然后再加载我们的应用程序字节码,才能让应用程序字节码运行在JVM这个容器里。
但也有一些语言是没有虚拟机的,编译打包时就把依赖的核心库和其他特性支持,一起静态打包或动态链接到程序中,比如Golang和Rust,C#等。这样运行时就和程序指令组合在一起,成为了一个完整的应用程序,好处就是不需要虚拟机环境,坏处是编译后的二进制文件没法直接跨平台了。由于将程序的运行环境一起打包进了程序中,在Windows上编译的程序只包含与Windows有关的运行环境,因此,在Windows上编译的程序只能在Windows上运行,在Linux上编译的程序只能在Linux上执行。每到一个新的环境中,都得重新编译一下程序,无法一次编译,到处运行。
跨平台
比如C++编译的代码后的代码无法跨平台使用,在Windows上编译的 程序只能在Window上运行,要在Linux上运行,则需要在Linux上重新进行编译。而Java则不管在哪个平台上编译,只要编译成了.class文件,可以在任何机器上运行,前提是该机器得安装上Java虚拟机。
字节码
Java bytecode
由单字节(byte)的指令组成,理论上最多支持256 个操作。
一个字节有8位,可以表示0000 0000
~ 1111 1111
,即0 ~ 255
。如果每一个数字都代表一个操作,那么最多支持256个操作。
实际上Java 只使用了200左右的操作码, 还有一些操作码则保留给调试操作。
根据指令的性质,主要分为四个大类:
- 栈操作指令,包括与局部变量交互的指令
- 程序流程控制指令
- 对象操作指令,包括方法调用指令
常用的方法调用的指令invokestatic
,顾名思义,这个指令用于调用某个类的静态方法,这是方法调用指令中最
快的一个。invokespecial
, 用来调用构造函数,但也可以用于调用同一个类中的private 方法, 以及
可见的超类方法。invokevirtual
,如果是具体类型的目标对象,invokevirtual 用于调用公共,受保护和
package 级的私有方法。invokeinterface
,当通过接口引用来调用方法时,将会编译为invokeinterface 指令。invokedynamic
,JDK7 新增加的指令,是实现“动态类型语言”(Dynamically Typed Language)支持而进行的升级改进,同时也是JDK8 以后支持lambda 表达式的实现基
础。 - 算术运算以及类型转换指令
由于Java的操作是基于栈的,所以字节码主要起着从栈中store和load变量的作用。
编译java文件生成class文件并查看
- 使用
javac
命令将.java文件编译成.class文件
PS D:\dev\Java\java-traning\jvm-learning\src\main\java\bytecode> javac HelloByteCode.java
- 使用
javap -c
命令查看.class文件
javap
是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。了解编译器内部工作
PS D:\dev\Java\java-traning\jvm-learning\src\main\java\bytecode> javap -c HelloByteCode.class
Compiled from "HelloByteCode.java"
public class bytecode.HelloByteCode {
public bytecode.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class bytecode/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: ldc #4 // String a
10: astore_2
11: aload_2
12: invokedynamic #5, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
17: astore_2
18: return
}
PS D:\dev\Java\java-traning\jvm-learning\src\main\java\bytecode>
HelloByteCode.java中的源代码如下:
package bytecode;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode helloByteCode = new HelloByteCode();
String str = "a";
str = str + "b";
}
}
javap命令输出的内容究竟代表什么意义呢?如下图所示:
如果以十六进制的形式打开.class文件,里面则是下图所下内容,实际上就是一串二进制数字,不过为了方便表示,用十六进制表示而已。
Java是用一个字节码来表示一系列操作,用两个十六进制的数就可以表示一个8位的二进制数。比如图中的0025
,可以理解为是两个操作,分别是0x00
和0x25
。那0x00表示什么操作,0x25又表示什么操作呢?
可以查看一下JVM的字节对照表,就可以知道这些数字究竟对照着什么操作了。网上有很多,比如这个链接:JVM字节对照表
.class
文件中的内容(用Sublime Text 3打开)
-
javap -c -verbose
指令能看到更多的内容
相比javap -c
命令查看到的内容多了一些头信息,多了常量池,行号等内容。
major version 55
对应着Java 11
若编译的时候再加上-g
的参数,eg:javac -g HelloByteCode.java
再使用javap -c -verbose
命令查看时就能查看到本地变量表的信息。
我们来分析一下LocalVariableTable
(本地变量表,这里的本地是指线程本身,也就是这个变量表是处于运行的线程中的,里面的变量只有这个线程才能访问。而比如常量池则不是线程独占的,是线程共享的):
-
start
指明变量从哪里开始起作用 length
不是指变量的长度,而是指该变量其作用的范围,比如变量args从第0行开始起作用,作用长度为19,也就是作用到第19行,这里的行不是指代码中的第几行,而是使指用javap -c
命令时每个指令前面标注的顺序。比如args
是从顺序0开始生效,长度为19,也就是生效到顺序为18,也就是return这里。-
slot
的意思是插槽,除了long和double所有的类型在本地变量的数组中占用一个slot,long和double需要两个连续的slot,因为这两个类型为64位类型,一个插槽为32位,在某种意义上也看作是变量的一个序号,按顺序将变量插入插槽中排列起来,方便到时候用。 Name
对应着代码中的三个变量:args
,helloByteCode
,str
。Signature
表示该对象对应的类的一个签名
如果是基本的数据类型呢?int 对应一个大写字母I,char对应一个大写字母C,依次类推。
分析一段代码的字节码
源代码:
package bytecode;
public class StringOperation {
public static void main(String[] args) {
String a = "a";
String b = "a";
String c = new String("a");
String d = "a" + "b";
String e = "a" + b;
}
}
stack=3, locals=6, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #2 // String a
5: astore_2
6: new #3 // class java/lang/String
9: dup
10: ldc #2 // String a
12: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
15: astore_3
16: ldc #5 // String ab
18: astore 4
20: aload_2
21: invokedynamic #6, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
26: astore 5
28: return
分析字节码
0: ldc
:将int,float或String型常量值从常量池中推送至栈顶。也就是说,我们以这种方法定义一个String变量:String a = "a";
会从常量池中找"a"这个值,然后赋给字符串变量a。程序在编译的时候就会将类似的常量,包括字符串常量,数字类型常量等放入常量池。然后在程序执行的时候就会去常量池中是否有相应的值。2: astore_1
:将栈顶引用型数值存入第二个本地变量。依次类推:astore_2
则表示将栈顶引用型数值存入第三个本地变量。这样的写法只能写到astore_3
,往后则需要这么写``astore 4。可能有点好奇,
String a = “a”;不是代码中的第一句吗,为什么不是先存储这个变量,为什么是这个变量是第二个被存入的,第一个被存入的是哪个变量?答案是第一个参数就是传入的参数
args`- 我们可以在该方法的本地变量表中找到
args
这个变量
但是在一些非静态的变量方法,我们可能会发现,即便我们没有传入任何参数,代码中的第一个变量变量依然是第二个被存储到栈中的。
比如下面这张图。
这是因为在存储f
这个变量之前,我们存储了当前类的一个自引用this
,我们可以在LocalVariableTable
中查看到this这个变量。
6: new
:创建一个对象, 并将其引用引用值压入栈顶。为什么这里会发生创建对象的操作呢?因此在我们的代码中这样创建了一个字符串变量c:String c = new String("a");
通过这种方式创建。的String变量,不仅会在常量池中存储相应的值,还会在堆中存储其对象。9: dup
:复制栈顶数值并将复制值压入栈顶。为什么需要复制呢?我们这时候new了一个String类型的对象,该对象对应的引用会被放入栈顶,进行复制之后,又会放入一个相同的引用放入栈顶,那这不就是重复了吗?为什么要放两个?因为执行String c = new String("a");
的时候不是执行了第6步new操作以后就已经完全创建好c这个对象了,还需要执行第12步的invokespecial
来完成该对象的初始化操作,这就相当于是执行构造函数,但是我们知道构造函数是没有返回值的,执行完毕以后,该引用也跟着被弹出栈了。所以如果不在前面再复制一个c
变量的话,执行完毕以后栈里就没有c
这个变量了。12: invokespecial
:调用超类构建方法, 实例初始化方法, 私有方法。12: invokespecial #4
中的#4
指向的是常量池中索引为#4
的变量,常量池中索引为#4
的变量其实是一个方法,相当于调用了String的初始化构造方法。
Constant pool:
......
#3 = Class #29 // java/lang/String
#4 = Methodref #3.#30 // java/lang/String."<init>":(Ljava/lang/String;)V
......
#9 = Utf8 <init>
......
#30 = NameAndType #9:#38 // "<init>":(Ljava/lang/String;)V
......
#38 = Utf8 (Ljava/lang/String;)V
......
16: ldc
:这句代码是发生在c
变量创建并完成以后。而此时我们应该要执行String d = "a" + "b";
这句代码,但是我们发现,似乎并没有发生拼接操作,也就发生了ldc
这一个从常量池中读取值到栈顶的操作。16: ldc #5
这句中的#5
在常量池中是这样的:#5 = String #31 // ab
。在编译的时候,编译器遇到"a" + "b"这样常量值的拼接操作,会将其直接进行合并,然后作为"ab"这么一个整体放入常量池中,不会在代码执行的时候再发生拼接操作了。20: aload_2
:将第三个引用类型本地变量推送至栈顶。我们要执行这句代码String e = "a" + b;
首先需要读取出b的值,b也就是第三个变量。21: invokedynamic #6, 0
:调用动态方法。由于这里要发生String e = "a" + b;
这样的拼接操作,在Java9之后 ,String的拼接是调用了StringConcatFactory.makeConcatWithConstants
方法进行字符串拼接优化;而Java 8则是通过转换为StringBuilder
,使用StringBuilder
的append
方法来进行字符串拼接。接着说:为什么要调用方法进行拼接呢?因为我们使用的String字符串,本质上是一个对象,对象之间是没有四则运算的操作的,对于对象,我们能做的只能是调用对象的方法。因此,我们在用+
进行字符串的拼接的时候,相当于是用了Java的语法糖(所谓的语法糖可以理解为是一种简写,目的是用更少的代码实现同样的功能),本质上还是调用了对象的相应的方法来进行操作。
String是线程安全的,StringBuilder是线程不安全的,将String的拼接转换为StringBuilder的append操作怎么能保证线程安全吗?
因为只有在方法中的局部变量里才会这么操作,我们首先要明白一点,之所以出现并发问题,是因为多个线程需要共享同一个变量。方法中的局部变量本质上是用完就完了的,本质上就不是共享的,不会造成所谓的并发问题,所以对方法里的局部变量使用StringBuilder的拼接操作,不会对String的线程安全造成影响。
常量池中对应的内容
Constant pool:
#6 = InvokeDynamic #0:#35 // #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
......
#35 = NameAndType #41:#42 // makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
......
#41 = Utf8 makeConcatWithConstants
#42 = Utf8 (Ljava/lang/String;)Ljava/lang/String;
参考:极客时间Java训练营