0%

句柄是什么?

指针的指针。

jvm中内存里的对象都有一个指针指向对象的开始地址,句柄就是指向对象的指针。

句柄解决了什么问题?

对实际的对象资源做了一层代理,屏蔽细节,避免了直接操控资源可能的危险。

弄了句柄,对象实际位置在内存中可以随意变化,比如标记整理垃圾回收后对象被移动到其他地方。

虚拟内存机制,可能会将已经载入内存的数据换到外存硬盘上,这样对象的地址就会变动,其他地方引用这块被换走的地址也要更新,如果使用这块数据的地方引用的是句柄,这样实际数据在内存的位置可以随意变化,使用数据的地方还是引用固定的地址,不用更新。

Windows系统中有许多内核对象(这里的对象不完全等价于”面向对象程序设计”一词中的”对象”,虽然实质上还真差不多),比如打开的文件,创建的线程,程序的窗口,等等。这些重要的对象肯定不是4个字节或者8个字节足以完全描述的,他们拥有大量的属性。为了保存这样一个”对象”的状态,往往需要上百甚至上千字节的内存空间,那么怎么在程序间或程序内部的子过程(函数)之间传递这些数据呢?拖着这成百上千的字节拷贝来拷贝去吗?显然会浪费效率。那么怎么办?当然传递这些对象的首地址是一个办法,但这至少有两个缺点:

  1. 暴露了内核对象本身,使得程序(而不是操作系统内核)也可以任意地修改对象地内部状态(首地址都知道了,还有什么不能改的?),这显然是操作系统内核所不允许的;

  2. 操作系统有定期整理内存的责任,如果一些内存整理过一次后,对象被搬走了怎么办?

所以,Windows操作系统就采用进一步的间接(可以理解为进一步的抽象的过程):在进程的地址空间中设一张表,表里头专门保存一些编号和由这个编号对应一个地址,而由那个地址去引用实际的对象,这个编号跟那个地址在数值上没有任何规律性的联系,纯粹是个映射而已。

在Windows系统中,这个编号就叫做”句柄”。

参考:
句柄的概念

为什么叫句柄这个名字?

从名字上说,handle是指中间媒介,例如门把手是door handle,刀柄是knife handle。

所以文件句柄file handle以即其他资源句柄,也是这个中间媒介的意思,通过这个媒介操作资源。

参考:

JVM对象访问定位

JVM通过栈上的reference类型数据来操作堆上的具体对象。

由于reference数据只是规定了一个指向对象的引用,没有定义如何去定位访问对象的具体位置。

主流的实现方式有两种:

  • 句柄
    在堆中划分句柄池,reference存储对象的句柄地址,句柄包含对象实例数据和类型数据的各自具体地址信息。
    • 好处:reference中的数据是稳定的句柄地址 对象被移动只会改变句柄中的信息 不会改变句柄的地址 reference不需要变化。
    • 坏处:增加了指针定位的开销。
  • 直接指针
    栈上reference类型数据槽中直接存储堆对象的地址。
    • 好处:直接访问对象 减少指针定位开销。
    • 坏处:当对象内存地址发生变化 reference中数据也需要调整。

参考:

  • 《深入理解Java虚拟机(第2版)》 2.3.3 对象的访问定位 48页

TCP为什么要三次握手?为什么不能两次握手或四次握手?

  1. 三次握手是确认客户端和服务端的发送和接受报文的能力是否正常。
  2. 如果是两次握手,服务端受到客户端的历史SYN报文就建立连接,会空耗服务端资源。
  3. 确认双方发送数据的初始序号,因为都要给对方发送确认报文,表达已知悉。

三次握手是确认客户端和服务端的发送和接受报文的能力是否正常。三次握手足够判断这一点了,四次握手其实多余了,没有必要浪费时间去发送没有意义的报文。

如果不加确认,进行两次握手,服务端收到客户端的SYN报文就直接建立连接,有可能客户端的SYN报文发生了网络拥堵,客户端进行了超时重传,随后拥堵的报文又成功传到了服务端,服务端判断不了这是新的连接请求报文还是历史连接报文,如果服务端收到SYN报文直接建立连接,接受到了客户端历史的SYN报文,会发生空等待,消耗服务端的资源。

TCP三次握手过程

  1. 客户端发送SYN报文,告知对方初始序号,是否会告知窗口大小
  2. 服务端收到后,回应SYN+ACK报文,告知对方初始序号
  3. 客户端收到后,回应ACK报文,客户端建立连接,服务端收到ACK后建立连接

RFC 793 - Transmission Control Protocol 其实就指出了 TCP 连接使用三次握手的首要原因 —— 为了阻止历史的重复连接初始化造成的混乱问题,防止使用 TCP 协议通信的双方建立了错误的连接。

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

如果是两次握手,正常流程就是,客户端发送SYN,服务端接收到SYN开始建立连接,并发送ACK给客户端,客户端收到ACK开始建立连接。

客户端发送SYN后,可能发生拥堵,然后客户端超时重传SYN,接下来按照正常流程建立连接,然后过了一会服务端又收到了之前拥堵发过来的SYN,并不能确定它历史的连接还是新的连接,如果此时建立新连接就会一直等待客户端发送数据,而这只是一条历史的连接报文,客户端不会发送数据,服务端就会空等,浪费了资源。

如果是三次握手,服务端接收到SYN后,会发送SYN+ACK给客户端,由客户端来判断是否是历史连接,如果序号过期,则认为是历史连接,向服务端发送RST取消连接的建立。服务端是没办法判断客户端的序号是否过期。

TCP 建立连接时通过三次握手可以有效地避免历史错误连接的建立,减少通信双方不必要的资源消耗,三次握手能够帮助通信双方获取初始化序列号,它们能够保证数据包传输的不重不丢,还能保证它们的传输顺序,不会因为网络传输的问题发生混乱,到这里不使用『两次握手』和『四次握手』的原因已经非常清楚了:

  • 『两次握手』:无法避免历史错误连接的初始化,浪费接收方的资源;
  • 『四次握手』:TCP 协议的设计可以让我们同时传递 ACK 和 SYN 两个控制信息,减少了通信次数,所以不需要使用更多的通信次数传输相同的信息;

TCP三次握手中,服务端发送了SYN+ACK后,一直没有收到客户端的ACK会怎样?

server端如果在一定时间内没有收到的TCP会重发SYN-ACK。在Linux下,默认重试次数为5次,重试的间隔时间从1s开始每次都翻售,5次的重试时间间隔为1s, 2s, 4s, 8s, 16s,总共31s,第5次发出后还要等32s都知道第5次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s,TCP才会把断开这个连接。

TCP为什么要先建立连接才能传输数据?

因为如果不确定对方能够正常的接受和发送数据,一方单方面发送数据就是无意义的资源浪费。

TCP建立连接时做了什么事?

  1. 确认双方的发送能力、接受能力是否正常
  2. 确定双方发送报文的初始编号
  3. 确定窗口大小
  4. 确定MSS-最大传输包大小
  5. 确定是否使用SACK

TCP什么时候可以传输数据?

第三次握手客户端就可以携带数据了,因为受到服务端的SYN+ACK报文,确认了服务端的接受能力和发送能力是正常的

TCP建立连接时的SYN指令是做什么的?

同步SYN:连接建立时用于同步序号。当SYN=1,ACK=0时表示:这是一个连接请求报文段。若同意连接,则在响应报文段中使得SYN=1,ACK=1。因此,SYN=1表示这是一个连接请求,或连接接受报文。SYN这个标志位只有在TCP建产连接时才会被置1,握手完成后SYN标志位被置0。

什么是SYN攻击?

客户端发送SYN报文后,服务端会进入SYN_RCVD状态,但服务端发送出去的SYN+ACK报文没有应答,SYN报文发送多了后,会占满服务端的半连接队列

RST消息是什么时候发送?

建立连接出现错误就会发送RST报文关闭连接

1、端口未打开

2、请求超时

3、提前关闭

4、在一个已关闭的socket上收到数据

5、用于拒绝一个非法连接

静态类型:变量被声明时的类型;例如,Animal a = new Dog(), 静态类型为Animal, 实际类型为Dog

实际类型:变量所引用的对象的真实类型

重载方法是静态分派,即编译时多态

重写方法是动态分派,即运行时多态

单分派和多分派

方法的接收者:一个方法所属的对象

宗量:方法的接收者和方法的参数,只有这两种宗量

单分派:根据一个宗量进行对方法的选择

多分派:根据多于一个的宗量对方法进行选择

单分派和多分派取决于宗量,  方法调用者和方法参数都是宗量.

静态分派的方法调用:首先确定调用者的静态类型是什么,然后根据要调用的方法参数的静态类型(声明类型)确定所有重载方法中要调用哪一个, 需要根据这两个宗量来编译, 所以是静态多分派(多个宗量确定).

动态分派的方法调用:在运行期间,虚拟机会根据调用者的实际类型调用对应的方法, 只需根据这一个宗量就可以确定要调用的方法,所以是动态单分派(一个宗量)

到目前为止,Java 语言还是一门 “静态多分派、动态单分派” 的语言,也就是说在执行静态分派时是根据多个宗量判断调用哪个方法的,因为在静态分派时要根据不同的静态类型和不同的方法描述符选择目标方法,在动态分派的时候,是根据单宗量选择目标方法的,因为在运行期,方法的描述符已经确定好,invokevirtual 字节码指令根据变量的实际类型选择目标方法。

方法的描述符:方法参数类型+返回值类型

静态分派

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
classStaticDispatch { 
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
void sayHello(Humanguy) {
System.out.println("hello,guy");
}
void sayHello(Manguy) {
System.out.println("hello,man");
}
void sayHello(Womanguy) {
System.out.println("hello,woman");
}
public static void main(String[]args) {
Humanman=newMan();
Humanwoman=newWoman();
StaticDispatchdispatch=newStaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}

静态类型:是指对象 man 的 Human 类型, 静态类型本身是不会发送变化的,只有在使用时才会发送变化,静态类型在编译期间就可以确定一个变量的静态类型

实际类型:是指对象 man 的 Man 类型,实际类型在编译期间是不可确定的,只有在运行期才可确定

1
2
3
4
5
6
7
// 实际类型变化 
Human man = new Man();
man = new Woman();

// 静态类型变化
dispatch.sayHello((Man) man);
dispatch.sayHello((Woman) man);

所以第一段代码中,方法接收者是 StaticDispatch 对象,虽然两个变量的实际类型不同,但是静态类型是相同的都是 Human,虚拟机(准确的说是编译器)在实现重载时是通过参数的静态类型而不是实际类型做出判定的,并且在编译阶段,变量的静态类型是可以确定的,所以编译器会根据变量的静态类型决定使用哪个重载方法。

所有依赖静态类型定位目标方法的分派动作称为静态分派,静态分派典型的应用就是方法的重载。静态分派发生在编译阶段,所以方法的静态分派动作是由编译器执行的。

动态分派

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

public class DynamicDispatch {

static abstract class Human {
abstract void sayHello();
}

static class Man extends Human {
void sayHello() {
System.out.println("hello, man");
}
}

static class Woman extends Human {
void sayHello() {
System.out.println("hello, woman");
}
}

public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

从上图中,我们可以看到 main() 方法的字节码指令执行过程:

  • 0 ~ 7 句是调用 Man 类的实例构造器创建一个 Man 类的对象,并将对象的引用压入到局部变量表的第 1 个 Slot 中
  • 8 ~ 15 句是调用 Woman 类的实例构造器创建一个 Woman 类的对象,并将对象的引用压入到局部变量表的第 2 个 Slot 中
  • 16 ~ 17 句是将第 1 个 Slot 中的变量(也就是 man)加载到局部变量表中,并调用 sayHello() 方法,关键的就是第 17 句指令 invokevirtual

虽然第 17 句指令调用的常量池中的 Human.sayHello() 方法,但是最终执行的却是 Man.sayHello() 方法,这就要从 invokevirtual 指令的多态查找说起,invokevirtual 的查找过程如下所示:

  • 找到操作数栈顶的引用所指的对象的实际类型,记做 C
  • 在类型 C 中查找与常量中的描述符和简单名称相同的方法,如果找到则进行访问权限的判断,如果通过则返回这个方法的直接引用,查找结束;如果权限不通过,则返回 java.lang.IllegalAccessError 的异常
  • 如果在 C 中没有找到描述符和简单名称都符合的方法,则按照继承关系从下往上依次在 C 的父类中进行查找和验证过程
  • 如果最终还是没有找到该方法,则抛出 java.lang.AbstractMethodError 的异常

在上述 invokespecial 查找方法的过程中,最重要的就是第一步,根据对象的引用确定对象的实际类型,这个方法重写的本质。如上所述,在运行期内,根据对象的实际类型确定方法执行版本的分派过程叫做动态分派。

如何理解Java是静态多分派、动态单分派?

编译时期确定方法有两点依据:

  1. 调用方法的静态类型
  2. 方法的参数

所以说是静态多分派。

运行时期再确定方法只有一点依据,就是调用方法的实际类型,所以说是动态单分派。

所以最后实际的方法调用是编译和运行的结合,即调用方法的实际类型和参数(运行时期直接引用会根据调用方法的实际类型确定,编译时期调用方法的静态类型只不过是虚引用),按书上说法,在编译时期,方法名和参数就被确定了,运行时只需要确定调用者即可;所以方法选择上,编译时期缩小了范围,运行时期确定了具体的方法。

对于静态分派(重载),肯定会依赖接收者的静态类型与参数的静态类型(参数肯定存在,同名无参的方法只会有一个,不存在分派)。

对于动态分派(重写),虚拟机只会根据接收者的实际类型选择,而不会理睬参数的实际类型。

资料

TCP是什么?

面向连接、保证可靠性传输、基于字节流的传输层通信协议

TCP协议解决的是什么问题?

保证端到端数据传输的可靠性

TCP如何保证可靠性?

  1. 应用数据被分割成 TCP 认为最适合发送的数据块。
  2. TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
  3. 校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
  4. TCP 的接收端会丢弃重复的数据。
  5. 流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)
  6. 拥塞控制: 当网络拥塞时,减少数据的发送。
  7. ARQ协议: 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  8. 超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。

连接管理

建立连接三次握手,客户端SYN、服务端SYN+ACK、客户端ACK。

断开连接四次握手,客户端FIN、服务端ACK、服务端FIN+ACK、客户端ACK。

校验和

IP报文头的校验和只校验IP首部,TCP报文头的校验和校验的是首部和数据。

tcp报文头部有校验和,发送方生成校验和,接收方检验校验和,传输过程检验到报文有差错直接丢弃。

报文编号

  1. 检测丢失:接收方可以知道少了哪些数据。
  2. 检测乱序:发送方的报文可能因为网络拥堵乱序到达接收方,接收方可以按照序号重新排序拼接报文,再转交完整的数据给应用层。
  3. 检测重复:接收方可以根据编号丢弃已经收到的报文,因为同一个编号的报文可能会因为超时重传机制多次发送。

确认应答

每次接收方收到数据后,会回应ACK确认应答报文给发送方,发送方就知道了这个包没有丢失,而是已经传输成功了。

超时重传

每个报文发送后都会开启一个定时器,超时前收到该编号报文确认应答的报文时则取消计时,如果不能及时收到确认则会重新发送这个报文。

超时重传有两种情况,一种是发送的报文未达到接收方,一种是接收方发送的ACK确认报文未达到发送方;前者的情况在接收方收到报文后会正常的发送ACK报文;后者的情况会丢弃编号重复的报文直接发送ACK确认报文。

流量控制

如果发送方发生数据过快,接收方来不及处理,接受方只能丢弃数据,这样浪费了流量,增加了不必要的消耗,接收方要告诉发送方自己能接受处理数据的最大数据量是多少,别多发不能处理的数据。

是通过ACK报文头中的窗口大小字段来告知对方自己还能接受多少数据,要发送数据的一方的发送窗口大小就是这个ACK报文头中的窗口大小与拥塞窗口大小的较小值

拥塞控制

流量控制是假设网络不拥堵,只考虑两端数据处理能力,但是整个网络如果发生拥堵,还需另外的处理,即拥塞控制。

拥塞控制主要是四个算法:慢启动、拥塞避免、快重传、快恢复

面向字节流是什么意思?

消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,「前一个」消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃。

TCP报文头格式是怎样的?

TCP报文头部最大为60字节,头部固定20个字节,TCP Options最大为40字节

包含源端口、目的端口、序列号、确认号、数据偏移、保留位、控制位、窗口大小、校验和、紧急指针、选项等

控制位:

CWR:用于 IP 首部的 ECN 字段。ECE 为 1 时,则通知对方已将拥塞窗口缩小。
ECE:在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设置为 1,表示从对方到这边的网络有拥塞。
URG:紧急模式
ACK:确认
PSH:推送,接收方应尽快给应用程序传送这个数据。没用到
RST:该位为 1 表示 TCP 连接中出现异常必须强制断开连接。
SYN:初始化一个连接的同步序列号
FIN:该位为 1 表示今后不会有数据发送,希望断开连接。

为什么 TCP 协议有性能问题?

在弱网环境下(丢包率高)影响 TCP 性能的三个原因:

  1. TCP 的拥塞控制算法会在丢包时主动降低吞吐量;
  2. TCP 的三次握手增加了数据传输的延迟和额外开销;
  3. TCP 的累计应答机制导致了数据段的传输;

如何唯一的标识和确认一个TCP连接?

四元组。

源IP地址、源端口号、目的IP地址、目的端口号。

TCP最大连接数是多少?

服务端固定监听某个端口。

源IP地址在IP报文头中是32位。

源端口号在TCP报文头是16位。

不考虑其他因素,理论最大连接数是 2^32 * 2^16

为什么TCP报文头里没有数据长度,UDP报文头里有数据长度?

TCP数据长度 = IP总长度 - IP首部长度 - TCP首部长度

UDP数据长度 = IP总长度 - IP首部长度 - UDP首部长度

UDP数据长度是可以通过IP总长度减去报文头长度算出来了的。

UDP报文头里有数据长度,是为了网络设备硬件设计和处理方便,首部长度需要是4字节的整数倍。

类加载器是干什么的?

负责将class文件(Java编译后的字节码文件)读取到内存,并转换为java.lang.Class的一个实例。

常见类加载器有哪些?分别是什么作用?

从虚拟机角度,分为启动类加载器和非启动类加载器。

  1. 启动类加载器(BootstrapClassLoader)是由C++实现,是虚拟机的一部分
  2. 其他的类加载器,都是由Java实现,全部继承自ClassLoader。

Java系统提供的类加载器主要有如下三种:

  1. BootstrapClassLoader(启动类加载器):加载 ${JAVA_HOME}\lib 或 -Xbootclasspath指定的目录下的类。
  2. ExtClassLoader(扩展类加载器):加载${JAVA_HOME}\lib\ext或环境变量java.ext.dir指定目录的类。
  3. AppClassLoader(应用程序类加载器):加载用户项目指定的classpath里的类,可通过ClassLoader的getSystemClassLoader()方法获得,所以也会称为系统类加载器。

如何确定类的唯一性?

问题等同于:如何判断两个类是否是同一个类?

对于任何一个类,由类加载器和类本身确立其在虚拟机中的唯一性。

  • 首先类本身信息要相同,如类的全限定名。
  • 其次必须是同一个类加载器加载的。

相同的类,被不同的类加载器加载,会被视为不同的类。

为什么要这样设计?

涉及到安全性问题,要配合双亲委派模型的机制来解释。

双亲委派(Parents Delegation)模型是什么?

直接看ClassLoader的loadClass()方法的源码就很直白。

  • 除了启动类加载器,其他每个类加载器都有一个父类加载器(在ClassLoader源码中体现为类型为ClassLoader的parent成员变量,是组合关系而非继承关系)。
  • 加载类时(ClassLoader的loadClass方法)会先通过父类加载器加载类,层层传递到顶层的启动类加载器(parent为null就通过native方法调用启动类加载器加载类)。
  • 只有当父类加载器无法加载类,才会用当前的类加载器尝试加载类。

这个逻辑是Java设计者推荐的加载方式,并不是强制约束,开发者可以自定义类加载器复写loadClass()方法来改变这一流程。

双亲委派解决了什么问题?为何要这样设计?

是为了基础核心类加载的安全性考虑。

java.lang.Object这种系统是存放在rt.jar中的,无论哪个类加载器要加载这个类,都会委派启动类加载器加载Object,这样可以保证在各种环境下,加载出的Object都是<JAVA_HOME>\lib\rt.jar的。

如果没有双亲委派机制,用户自己也定义了一个java.lang.Object,写了有问题的代码,放在用户项目的Classpath,那就会影响所有类的基础行为,因为Object是所有类的父类。所以也会要求不同的类加载器加载同一个类属于不同的类。

双亲委派模型很好的解决了各个类加载器的基础类统一问题,越基础越公共的类越是由上层的类加载器加载。同时保证了基础类的不会被随意的篡改,保证安全感。

双亲委派会有哪些无法解决的问题?应该怎么解决?

当基础类是接口,需要加载不同的接口实现类,实现类并不在当前类加载器管控的类的范围里,双亲委派的类加载顺序就要反过来,由父类加载器去请求子类加载器加载接口实现类。

典型的场景是SPI(Service Provider Interface)依赖注入框架。

SPI约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。

SPI的核心类ServiceLoader是jdk的类,位于rt.jar中,由BootstrapClassLoader加载,接口实现类肯定是不能由BootstrapClassLoader加载,BootstrapClassLoader只加载jdk核心类,只能由AppClassLoader或用户自定义的ClassLoader来加载,按照双亲委派机制的话,这样就无法完成了,只能逆向请求加载。

解决之道就是使用线程上下文加载器(ContextClassLoader),SPI在加载服务接口实现类时,调用Thread的getContextClassLoader()来加载实现类,可以通过Thread的setContextClassLoader(ClassLoader cl)方法来设置线程上下文类加载器,如果没有手动设置过,默认会继承父线程的上下文类加载器,如果父线程也没有设置过,则默认采用AppClassLoader

ServiceLoader.load()方法中要去加载实现类,需要用一个ClassLoader来加载,但是ServiceLoader是系统类,获取到的ClassLoader是BootstrapClassLoader,所以需要从一个地方获取实现类所处位置的ClassLoader,通过Thread的contextClassLoader就可以实现这个效果。

一个类加载器明明只有一个parent,Parents Delegation为什么被翻译为双亲委派?

翻译错误,以讹传讹。

由于除了启动类加载器其他类加载器都有parent,也就是每个非启动类加载器都有多个祖先,所以应该翻译为祖先委派更妥当

参考:

Class.forName和ClassLoader的loadClass有什么区别?动态加载一个类时,应该选用哪一个方式?

ClassLoader在loadClass后仅将字节码加载到内存,不会对类进行初始化。

Class.forName()在加载类到内存后后,会对类进行初始化,即执行类的static代码块和static变量的赋值,也可以传参控制不初始化。底层也是用ClassLoader来loadClass的。

Class.forName()用的调用者类的类加载器加载类,也可以手动传递classLoader参数

如果期望一个类加载后要执行static代码块做初始化操作,应该使用Class.forName(),如jdbc里的初始化。

参考:

参考

基本过程

  1. 域名解析
  2. tcp连接
  3. tls握手
  4. 发送http请求
  5. ARP寻找路由器
  6. 以太网传输数据帧
  7. 路由转发
  8. 服务器返回http响应
  9. 浏览器解析http响应体渲染页面
  10. TCP四次挥手断开连接

域名解析

主流网络通信协议是TCP/IP,数据在网络中的传输是通过IP协议在各个路由器之间传输,找到服务端机器的,IP协议只认IP地址,IP地址全是数字,对于人来说不方便记忆,所以搞了域名,全是英文。所以第一步要先解析域名为IP地址。

  1. 先看浏览器缓存有没有对应IP。
  2. 没有则查询操作系统hosts文件里是否有对应IP。
  3. 没有则向本地DNS服务器发起域名解析。
  4. 如果本地DNS服务器有对应IP缓存,则直接返回。
  5. 没有则向根域名服务器请求解析域名,根域名服务器无法解析域名,给出下一级域名服务器地址,本地DNS服务器继续请求,这样一级一级的直到解析出IP地址。

TCP连接

浏览器使用HTTP协议传输数据,HTTP要求传输要可靠,所以HTTP底层是基于TCP协议的。

TCP协议为了可靠性,一开始是要通过三次握手建立连接,验证通信双方的发送数据和接受数据是正常。

采用三次握手而不是两次握手,是为了防止已失效的连接请求报文突然又传送到了服务端,因而产生错误。

三次握手还会交换报文初始序号、滑动窗口大小、最大报文长度等信息,这些都是用来保证传输的可靠性的。

HTTP请求

HTTP请求分为请求行、请求头、请求体。

  • 请求行规定了 请求方法类型、url、http协议版本。
  • 请求头的功能很多,比如说缓存、压缩、支持的编码等,看实际的需求。
  • 请求体是详细的数据部分。

浏览器输入一个url访问,都是get请求。

数据到达传输层,这里使用的是TCP协议,给数据加上TCP报文头,在报文头中填充源端口号、目的端口号、数据字节数编号、接受窗口大小等数据。

其中端口号的目的是为了把数据交给特定的应用程序做处理,端口号是为了区分同一台主机上不同的应用程序。

TCP协议还有报文编号、超时重传、流量控制、拥塞控制等机制,都是为了保证 传输的可靠性,保证数据传输到位。这些机制对于HTTP协议是无感的。

TCP分段

如果HTTP报文过长,超过TCP规定的最大报文长度,就会分为多个TCP报文传输,TCP分段是为了防止IP层对数据进行分片,因为IP层分片后,除了第一个分片有TCP报文头,其他IP分片 没有TCP报文头,这样如果一个IP分片丢失,就要重传所有的IP分片,因为没办法定位哪个分片丢失,不方便做超时重传,降低了传输吞吐量。

IP层对数据进行分片是为了防止IP报文大小超过数据链路层的最大传输单元大小,IP数据过长,到数据链路层就无法传输 。

ARP寻址

TCP报文到IP层后,会加上IP协议报文头,添加源主机IP地址,目的主机IP地址。IP地址是用来定位网络中的主机的。

源主机先检查源主机IP地址和目的主机IP地址是否在同一个网络中。

局域网传输

如果在同一个网络中,接下来就是局域网通信了。局域网通信识别主机是通过MAC地址来识别的 ,而不是IP地址,所以第一步要先获取主机的MAC地址。这就要用到ARP协议,ARP叫地址解析协议,是用来做IP地址到MAC地址的映射。

首先源主机在本地局域网中发送一个ARP广播请求,目的主机收到广播请求后对比IP地址是自己的,就返回一个ARP响应包,把目的主机的MAC地址告诉源主机。ARP协议会缓存IP地址和MAC地址的映射关系,下一次就不用发广播来获取MAC地址了,节省通信时间。接下来源主机就把IP数据包加上数据链路层的帧首部,填充源主机和目的主机的MAC地址,通过以太网等局域网手段跟目的主机通信。

广域网传输

如果不在同一个网络中,要将数据发送到网络中,也要先通过ARP获取到网关路由器的MAC地址,源主机把数据传递给网关路由器,网关路由器把数据帧首部去掉,发送IP数据报。

路由转发

路由器转发数据,要在路由表中查询是否有能与目的IP地址匹配的条目,如果匹配了主机地址,则发送数据给目的主机 ,否则转发给下一个网络的网关路由器。

数据应该转发给哪个路由器,是根据路由算法来决定的,路由算法会计算得出一个传输代价最小的路径。

在大型、易变的网络中,会用到动态路由算法,一般使用链路状态路由算法,因为传播的数据量小,节省CPU、内存和网络带宽。

每个路由器都会将自己与邻居节点之间的链路状态广播出去,发送到整个网络,这样每个路由器都会有网络中所有路由器的状态,形成全网的拓扑视图,然后可以用迪杰斯特拉最短路径算法求出最短路径,就知道路由转发的时候要发给哪个路由器了。当某台路由器链路状态发生变化,采用洪泛法向所有路由器发送状态信息,使其他路由器重新计算最佳路径重新生成路由表。

路由器之间的通信,也是要走局域网通信的那一套流程,即获得下一跳路由器的MAC地址,然后通过以太网传输数据帧。

服务端收到消息后也是同样的反向操作流程。

类加载流程

类从被加载到虚拟机内存,到卸载出内存为止,常规的生命周期如下:

  1. 加载(Loading)
  2. 链接(Linking)
  3. 初始化(Initialization)
  4. 使用(Using)
  5. 卸载(Unloading)

其中链接(Linking)又可以分为:

  1. 验证(Verification)
  2. 准备(Preparation)
  3. 解析(Resolution)

加载、验证、准备、初始化,这几个顺序是确定的,解析某些情况下发生在初始化之后,这是为了支持Java语言的运行时绑定(动态绑定)。

参考

  • 《深入理解Java虚拟机(第2版)》7.3节 类加载的过程

加载(Loading)

加载:

  1. 通过类的全限定名来获取类的二进制字节流。
  2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

第1条,通过类的全限定名来获取类的二进制字节流,jvm没有限制具体的实现,历史上的典型实现有:

  1. 从zip包中读取,如jar。
  2. 从网络中获取。
  3. 运行时动态生成,如动态代理,在java.lang.reflect.Proxy中用了ProxyGenerator.generateProxyClass为特定接口生成形式。为“*$Proxy”的代理类二进制字节流。
  4. 从其他文件生成,如由文件生成class。
  5. 通过数据库读取。

非数组类加载类中获取类的二进制字节流的动作自定义性最强,既可以用系统提供的引导类加载器完成,也可以用用户自定义类加载器完成,开发者可以自定义一个ClassLoader并重写findClass方法来控制字节流的获取。

数组类的加载由虚拟机直接创建。
数组元素类是基本类型,虚拟机会把数组类标记为与BootstrapClassLoader关联。

验证(Verification)

类加载后为什么需要验证?

由于类加载过程不要求class一定从Java源代码编译而来,可以通过任何途径产生,在字节码可以做到Java语法层面做不到的事情,也有可能不符合正常的Java语法规范,所以如果不对class字节流进行验证,可能会使系统遭受攻击。

类的验证过程是怎样的?

大致会完成以下验证动作:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

文件格式验证

例如:

  1. 是否以magic number 0xCAFEBABE开头
  2. 主次版本号是否在当前虚拟机处理范围
  3. 常量池中的常量是否有不被支持的的常量类型

元数据验证

是对字节码描述的元数据信息进行语义分析,保证元数据信息符合Java语言规范,如:

  1. 这个类是否有父类。
  2. 这个类是否继承了不允许被继承的类(被final关键字修饰的类)。
  3. 如果这个类不是抽象类,是否实现了父类或接口之中的要求实现的方法。

类的元数据指什么?

一个class文件中的信息分为代码和元数据两部分,其中代码是指方法体里的java代码,在class文件中表现为Code属性,其他所有数据项都用于描述元数据,包括类、字段、方法定义等。

参考《深入理解Java虚拟机(第2版)》183页 6.3.7 属性表集合

字节码验证

对方法体做校验分析,通过数据流和控制流分析,确定程序语义是否是合法的、符合逻辑的,如:

  1. 保证跳转指令不会跳转到方法体以外的字节码指令上。
  2. 保证方法体内类型转换是有效的。

通过了字节码验证,也无法保证程序就是没有错误的,无法通过程序准确的检查出代码是否在有限的时间内结束运行,即离散数学中的Halting Problem。

参考:

符号引用验证

发生在虚拟机将符号转为直接引用的时候,这个动作将在连接的第三个阶段——解析阶段中发生。

符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,内容如根据名能否找到类、方法字段等。

这阶段目的是确保解析动作的正常执行,如果无法通过符号验证,将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethdError等。

准备(Preparation)

类的准备阶段会完成:

  1. 为类的静态变量分配内存
  2. 设置变量初始值

如果是常量,则编译时存放进引用其的class的常量池中,加载时存入方法区的运行时常量池。

类变量是指static修饰过的变量。

在类变量未被final修饰时,变量的初始值是数据类型的零值。

例如 public static int value = 123; 语句,在准备阶段赋值是0(int类型的零值)而不是123。

解析(Resolution)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic References)
    以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用(Direct References)
    直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在内存地址中。

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了他们都不可能通过继承而别的方式重写其他版本,因此他们都适合在类加载阶段进行解析。
与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法。

方法调用并不等于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作。Class文件的编译过程不包含编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

初始化(Initialization)

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 代码。

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则根据程序员自己设置的变量赋值。初始化阶段是执行类构造器<clinit>() 方法的过程。

<clinit>() 方法生成的细节

  • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的前后顺序决定的,静态语句块中只能访问定义在它之前的变量,定义在它后面的变量,可以赋值,但是不能访问。
  • <clinit>() 方法与类的构造函数 <init>()方法不同,它不需要显示地调用父类构造器,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>() 方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>() 方法肯定是 Object 的。
  • <clinit>()方法对类或接口来说不是必需的,如果一个类中没有静态语句块也没有对变量赋值的操作,那么编译器不为这个类生成<clinit>() 方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,接口不需要首先执行父类的<clinit>() 方法。只有当用到父类中的变量时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>() 方法。
  • 虚拟机会保证一个类的<clinit>() 方法在多线程中被正确的加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>() 方法,其它线程被阻塞。

另外就是在同一个类加载器下,<clinit>() 方法只会被执行一次,也就是说一个类型只会被初始化一次。

类什么情况下会触发初始化?

以下五种情况必须对类进行初始化(加载、验证、准备自然必须在初始化之前完成):

  1. 遇到new、putstatic、getstatic、invokestatic这几条字节指令;生成这 4 条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入到常量池的静态字段除外)以及调用一个类的静态方法的时候。
  2. 被final修饰的静态字段在编译期已经被把结果存入从常量池了,会触发static的指令吗,需要调研试验一下。
  3. 通过Class.forName反射调用一个类,参数中初始化选项默认为true。
  4. 初始化一个类,要先触发父类的初始化;初始化一个接口时只有在真正使用到父接口的时候(如引用接口中定义的常量)才初始化。
  5. 虚拟机启动时,会先初始化主类,即含有main方法的类。
  6. 使用jdk7的动态语言机制时,一个MethodHandle实例最后解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,需要初始化这些方法句柄对应的类。

不会触发类初始化的典型场景

  1. 调用父类的静态成员变量,只会执行父类的static代码块,不会执行子类的static代码块,即子类没有初始化。
  2. 创建一个类的数组,不会触发数组元素类初始化(元素类的static代码块不会执行)。
  3. A类只引用了B类中定义的常量(static final定义的基本类型字面量或字符串字面量的属性),不会触发该B类的初始化,因为在编译阶段通过常量池传播优化,已经将B类的常量值存储到了A类常量池中,以后A类对B类中常量的引用都转换为对A类自身常量池中的引用。

卸载(Unloading)

类卸载时机

类的卸载就从虚拟机中移除类,这个由虚拟机的垃圾回收机制决定,一般要满足下面的条件:

  1. 该类所有的实例对象都已被回收。
  2. 该类的类加载器对象已被回收。
  3. 该类的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类。

参考

  • 《深入理解Java虚拟机(第2版)》第7章 虚拟机类加载机制

Class常量池

Class常量池在哪,是做什么的?

每个Class文件中都有常量池,位于魔数和主次版本号之后,是Class文件中第一个出现的表类型的数据项,占用空间最大的数据项之一,也是与其他项目关联最多的数据类型。

Class常量池主要存放:

  1. 字面量(Literal)
  2. 符号引用(Symbolic References)

可以理解为Class文件中的资源仓库。

参考《深入理解Java虚拟机(第2版)》168页 6.3.2 常量池。

字面量是什么?

直观的数据值。

1
2
3
int i = 100;
float f = 2.3f;
String s = "abc";

100、2.3f、”abc”是字面量

0x1FB等十六进制、八进制、二进制等直观的数据值也是字面量。

参考:

符号引用是什么?

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。

也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。

当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

因为java要支持动态性,所以不能在编译期就确定最终的内存布局。

参考《深入理解Java虚拟机(第2版)》168页 6.3.2 常量池。

字段描述符和方法的描述符是什么?

字段的描述符唯一确定一个字段,方法的描述符唯一确定一个方法。

参考:

Class常量池的实际存储格式是什么样的?

常量池整体上分为两部分:

  • 常量池计数器
  • 常量池数据区

常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。

常量池数据区:数据区是由(constant_pool_count-1)个cp_info结构组成,一个cp_info结构对应一个常量。在字节码中共有14种类型的cp_info(如下图6所示),每种类型的结构都是固定的。

具体以CONSTANT_utf8_info为例,它的结构如下图7左侧所示。首先一个字节“tag”,它的值取自上图6中对应项的Tag,由于它的类型是utf8_info,所以值为“01”。接下来两个字节标识该字符串的长度Length,然后Length个字节为这个字符串具体的值。从图2中的字节码摘取一个cp_info结构,如下图7右侧所示。将它翻译过来后,其含义为:该常量类型为utf8字符串,长度为一字节,数据为“a”。

参考

Class常量池一个具体内容的例子?

定义如下的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class JavaBean{ 
private int value = 1;
public String s = "abc";
public final static int f = 0x101;

public void setValue(int v){
final int temp = 3;
this.value = temp + v;
}

public int getValue(){
return value;
}
}

生成的class常量池如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Constant pool: 
#1 = Methodref #6.#29 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#30 // JavaBasicKnowledge/JavaBean.value:I
#3 = String #31 // abc
#4 = Fieldref #5.#32 // JavaBasicKnowledge/JavaBean.s:Ljava/lang/String;
#5 = Class #33 // JavaBasicKnowledge/JavaBean
#6 = Class #34 // java/lang/Object
#7 = Utf8 value
#8 = Utf8 I
#9 = Utf8 s
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 f
#12 = Utf8 ConstantValue
#13 = Integer 257
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 LocalVariableTable
#19 = Utf8 this
#20 = Utf8 LJavaBasicKnowledge/JavaBean;
#21 = Utf8 setValue
#22 = Utf8 (I)V
#23 = Utf8 v
#24 = Utf8 temp
#25 = Utf8 getValue
#26 = Utf8 ()I
#27 = Utf8 SourceFile
#28 = Utf8 StringConstantPool.java
#29 = NameAndType #14:#15 // "<init>":()V
#30 = NameAndType #7:#8 // value:I
#31 = Utf8 abc
#32 = NameAndType #9:#10 // s:Ljava/lang/String;
#33 = Utf8 JavaBasicKnowledge/JavaBean
#34 = Utf8 java/lang/Object

字面量:

这里需要说明的一点,上面说的存在于常量池的字面量,指的是数据的值,也就是abc和0x101(257),通过上面对常量池的观察可知这两个字面量是确实存在于常量池的。

而对于基本类型数据(甚至是方法中的局部变量),也就是上面的private int value = 1;常量池中只保留了他的的字段描述符I和字段的名称value,他们的字面量不会存在于常量池。

符号引用:

对于方法中的局部变量名,class文件的常量池仅仅保存字段名。

参考:

一个方法名最多可以有多长?

在HotSpot VM中,运行时常量池里,CONSTANT_Utf8_info可以表示Class文件的方法、字段等等,其结构如下:

首先是1个字节的tag,表示这是一个CONSTANT_Utf8_info结构的常量,然后是两个字节的length,表示要储存字节的长度,之后是一个字节的byte数组,表示真正的储存的length个长度的字符串。这里需要注意的是,一个字节只是代表这里有一个byte类型的数组,而这个数组的长度当然可以远远大于一个字节。当然,由于CONSTANT_Utf8_info结构只能用u2即两个字节来表示长度,因此长度的最大值为2byte,也就是65535。

Android虚拟机的Class常量池跟JVM的有什么不同?

Java从设计之初就非要支持分离编译(separate compilation)与按需动态类加载(on-demand dynamic class loading),导致Java的Class文件必须独立的(self-contained)——每个Class文件必须自己携带自己的常量池,其主要信息是字符串与若干其它常量的值,以及用于符号链接的符号引用信息(symbolic reference)。

如果大家关注过Class文件的内容的话,会知道其实通常Class文件里表示程序逻辑的代码部分——“字节码”——只占Class文件大小的小头;而大头都被常量池占了。而且多个Class文件的常量池内容之间常常有重叠,所以当程序涉及多个Class文件时,就容易有冗余信息,不利于减少传输/存储代码的大小。

大家或许还记得Google在Google I/O 2008的Dalvik VM Internals演讲里,Dan得意的介绍到Dalvik的Dex格式在未压缩的情况下都比压缩了的JAR文件还小么?

Dan准确的介绍了Dex体积更小的原因:一个Dex相当于一个或多个JAR包,里面可以包含多个Class文件对应的内容。一个Dex文件里的所有Class都共享同一个常量池,因而不会像Class文件那样在多个常量池之间有冗余。这样Dex文件就等同于在元数据层面上对JAR文件做了压缩,所以前者比后者更小。

参考:

字符串常量池

字符串常量池在内存区域中的哪一块?

方法区实际上是在一块叫“非堆”的区域包含——可以简单粗略的理解为非堆中包含了永生代,而永生代中又包含了方法区和字符串常量池。

其中的Interned String就是全局共享的“字符串常量池(String Pool)”,和运行时常量池不是一个概念。但我们在代码中申明String s1 = “Hello”;这句代码后,在类加载的过程中,类的class文件的信息会被解析到内存的方法区里。

全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。

在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

String s1 = “Hello”,到底有没有在堆中创建对象?

class文件里常量池里大部分数据会被加载到“运行时常量池”,包括String的字面量;但同时“Hello”字符串的一个引用会被存到同样在“非堆”区域的“字符串常量池”中,而”Hello”本体还是和所有对象一样,创建在Java堆中。

当主线程开始创建s1时,虚拟机会先去字符串池中找是否有equals(“Hello”)的String,如果相等就把在字符串池中“Hello”的引用复制给s1;如果找不到相等的字符串,就会在堆中新建一个对象,同时把引用驻留在字符串池,再把引用赋给str。

当用字面量赋值的方法创建字符串时,无论创建多少次,只要字符串的值相同,它们所指向的都是堆中的同一个对象。

字符串常量池本质是个什么东西?

字符串常量池是JVM所维护的一个字符串实例的引用表。

在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。

String”字面量” 是何时进入字符串常量池的?如何决定是否要复用字符串常量池中的驻留字符串?

在执行ldc指令时,该指令表示int、float或String型常量从常量池推送至栈顶。

ldc全称:load constant

就HotSpot VM的实现来说,加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池(即在StringTable中并没有相应的引用,在堆中也没有对应的对象产生),在执行ldc指令时,触发lazy resolution这个动作。

ldc字节码在这里的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。

在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去。

可见,ldc指令是否需要创建新的String实例,全看在第一次执行这一条ldc指令时,StringTable是否已经记录了一个对应内容的String的引用。

String.intern()用法和规则是什么?

1
2
3
4
5
6
7
8
public class Demo {
public static void main(String[] args) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}

以上代码,在 JDK6 下执行结果为 false、false,在 JDK7 以上执行结果为 true、false。

首先我们调用StringBuilder创建了一个”计算机软件”String对象,因为调用了new关键字,因此是在运行时创建,之前JVM中是没有这个字符串的。

在 JDK6 下,intern()会把首次遇到的字符串实例复制到永久代中,返回的也是这个永久代中字符串实例的引用;而在JDK1.7开始,intern()方法不再复制字符串实例,String 的 intern 方法首先将尝试在常量池中查找该对象的引用,如果找到则直接返回该对象在常量池中的引用地址。

因此在1.7中,“计算机软件”这个字符串实例只存在一份,存在于java堆中!通过3中的分析,我们知道当String str1 = new StringBuilder(“计算机”).append(“软件”).toString();这句代码执行完之后,已经在堆中创建了一个字符串对象,并且在全局字符串常量池中保留了这个字符串的引用,那么str1.intern()直接返回这个引用,这当然满足str1.intern() == str1——都是他自己嘛;对于引用str2,因为JVM中已经有“java”这个字符串了,因此new StringBuilder(“ja”).append(“va”).toString()会重新创建一个新的“java”字符串对象,而intern()会返回首次遇到的常量的实例引用,因此他返回的是系统中的那个”java”字符串对象引用(首次),因此会返回false。

在 JDK6 下 str1、str2 指向的是新创建的对象,该对象将在 Java Heap 中创建,所以 str1、str2 指向的是 Java Heap 中的内存地址;调用 intern 方法后将尝试在常量池中查找该对象,没找到后将其放入常量池并返回,所以此时 str1/str2.intern() 指向的是常量池中的地址,JDK6常量池在永久代,与堆隔离,所以 s1.intern()和s1 的地址当然不同了。

运行时常量池

运行时常量池是什么?

运行时常量池是方法区的一部分,所以也是全局贡献的,我们知道,jvm在执行某个类的时候,必须经过加载、链接(验证、准备、解析)、初始化,在第一步加载的时候需要完成:

  1. 通过一个类的全限定名来获取此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个类对象,代表加载的这个类,这个对象是java.lang.Class,它作为方法区这个类的各种数据访问的入口。

上面的第二条,将class字节流代表的静态存储结构转化为方法区的运行时数据结构,其中就包含了class文件常量池进入运行时常量池的过程,这里需要强调一下不同的类共用一个运行时常量池,同时在进入运行时常量池的过程中,多个class文件中常量池相同的字符串,多个class文件中常量池中相同的字符串只会存在一份在运行时常量池,这也是一种优化。

运行时常量池的作用是存储java class文件常量池中的符号信息,运行时常量池中保存着一些class文件中描述的符号引用,同时在类的解析阶段还会将这些符号引用翻译出直接引用(直接指向实例对象的指针,内存地址),翻译出来的直接引用也是存储在运行时常量池中。

运行时常量池 和 驻留字符串常量池 有什么联系?

jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

1
2
3
4
5
6
7
8
String str1 = "abc";
String str2 = new String("def");
String str3 = "abc";
String str4 = str2.intern();
String str5 = "def";
System.out.println(str1 == str3); // true
System.out.println(str2 == str4); // false
System.out.println(str4 == str5); // true

上面程序的首先经过编译之后,在该类的class常量池中存放一些符号引用,然后类加载之后,将class常量池中存放的符号引用转存到运行时常量池中,然后经过验证,准备阶段之后,在堆中生成驻留字符串的实例对象(也就是上例中str1所指向的”abc”实例对象),然后将这个对象的引用存到全局String Pool中,也就是StringTable中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询StringTable,保证StringTable里的引用值与运行时常量池中的引用值一致,大概整个过程就是这样了。

回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了,首先,在堆中会有一个”abc”实例,全局StringTable中存放着”abc”的一个引用值,然后在运行第二句的时候会生成两个实例,一个是”def”的实例对象,并且StringTable中存储一个”def”的引用值,还有一个是new出来的一个”def”的实例对象,与上面那个是不同的实例,当在解析str3的时候查找StringTable,里面有”abc”的全局驻留字符串引用,所以str3的引用地址与之前的那个已存在的相同,str4是在运行的时候调用intern()函数,返回StringTable中”def”的引用值,如果没有就将str2的引用值添加进去,在这里,StringTable中已经有了”def”的引用值了,所以返回上面在new str2的时候添加到StringTable中的 “def”引用值,最后str5在解析的时候就也是指向存在于StringTable中的”def”的引用值,那么这样一分析之后,下面三个打印的值就容易理解了。

“abc”的常量会被ldc指令从常量池推送到栈顶,ldc会触发动态解析。

参考资料

如何方便的查看class结构?

  • 方法一
    javap -verbose xxx.class

  • 方法二
    Intellij Idea装 jclasslib 插件
    代码编译后在菜单栏”View”中选择”Show Bytecode With jclasslib”

Class文件的整体结构是怎样的?

JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成。

class文件各数据项之间没有分隔符。

class文件采用类似于C语言的伪结构存储数据,这种伪结构只有两种数据类型:无符号数和表。

参考:

  • 《深入理解Java虚拟机(第2版)》第6章 类文件结构

方法表的具体存储格式是怎样的?

方法表也是由两部分组成:

  • 第一部分为两个字节描述方法的个数。
  • 第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性。

方法名索引、描述符索引,这两个的索引指的是在常量池中的索引,方法名和描述符都存储在class的常量池中,可以通过索引值在常量池中找到。

属性包括以下3个部分:

  1. “Code区”:源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
  2. “LineNumberTable”:行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
  3. “LocalVariableTable”:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。

参考:

class文件结构一个具体的案例?

参考《深入理解Java虚拟机(第2版)》第6章 类文件结构 6.3.7 属性表集合 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Classfile ~/Demo.class 
Last modified 2020-9-30; size 338 bytes
MD5 checksum d2cf9d824e949e4dcc98ac47657cba67
Compiled from "Demo.java"
public class Demo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // Demo.m:I
#3 = Class #20 // Demo
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LDemo;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 Demo.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 Demo
#21 = Utf8 java/lang/Object
{
public Demo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LDemo;

public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this LDemo;
}
SourceFile: "Demo.java"

参考资料

Object的finalize()方法什么时候被执行?

当垃圾回收器要宣告一个对象死亡时,至少要经过两次标记过程:

如果对象在进行可达性分析后发现没有和GC Roots相连接的引用链,就会被第一次标记。

GC会再判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。

如果对象覆盖finalize()方法且未被虚拟机调用过,那么这个对象会被放置在F-Queue队列中,并在稍后由一个虚拟机自动建立的低优先级的Finalizer线程区执行触发finalize()方法,但不承诺等待其运行结束。

执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。

Object.finalize()设计目的?

f对象逃脱死亡的最后一次机会。(只要重新与引用链上的任何一个对象建立关联即可。)但是不建议使用,运行代价高昂,不确定性大,且无法保证各个对象的调用顺序。可用try-finally或其他替代。

Object.finalize()使用场景

由于发生垃圾回收就会调用finalize(),所以它可以作为垃圾回收的监听回调。

在Android framework源码中BinderInternal实现了gc监听的功能,ActivityThread在gc发生时,如果当前内存不足时,则清理一些不必要的activity以释放内存。

再比如你调用了一些native的方法,可以要在finalize()里去调用C的释放函数。

参考