# 一.内存管理

# 1.并发与并行?

并发:同一时间同时发生,内部可能存在串行或者并行.又称共行性,是指处理多个同时性活动的能力。

并行:同一时间点同时执行,不存在阻塞.指同时发生两个并发事件,具有并发的含义。并发不一定并行,也可以说并发事件之间不一定要同一时刻发生。

区别:并发和并行是即相似又有区别的两个概念,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机系统中,每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。倘若在计算机系统中有多个处理机,则这些可以并发执行的程序便可被分配到多个处理机上,实现并行执行,即利用每个处理机来处理一个可并发执行的程序,这样,多个程序便可以同时执行。

image-20240117174246588

# 2.创建对象的过程?

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例 如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

从 Java 程序的视角来看,对象创建才刚刚开始 init 方法还没有执行,所有的字段都还为零。所以,一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

# 3.指针碰撞和空闲列表

假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种内存分配方式称为“指针碰撞”(Bump the Pointer)。

如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。

# 4.什么是 TLAB

对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理--实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过-XX:+-UseTLAB 参数来设定。

并发安全问题的解决方案:

JVM 提供的解决方案是,CAS 加失败重试和 TLAB

TLAB 分配内存:为每一个线程在 Java 堆的 Eden 区分配一小块内存,哪个线程需要分配内存,就从哪个线程的 TLAB 上分配 ,只有 TLAB 的内存不够用,或者用完的情况下,再采用 CAS 机制

# 5.对象的内存布局?

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

HotSpot 虚拟机的对象头包括两部分信息

第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为“MarkWord”。对象需要存储的运行时数据很多,其实已经超出了 32 位、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord 被设计成一个非固定的数据结松以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 MarkWord 的 32bit 空间中的 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0.

image-20240118164230498

对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说。查找对象的元数据信息并不一定要经过对象本身,另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据:是真正存储有效信息的部分.父类信息,子类信息都会记录下来.相同宽度的字段总是分配在一起.子类较窄的变量也可能插入到父类变量的缝隙之中.

对其填充:不是必须的,主要是占位符的作用,对象的大小必须是 8 字节的整数倍,对象头是 8 字节的整数倍,实例数据需要被对齐填充.

#32位JVM
Object Header: 8 字节
Instance Data: 0 字节 (因为没有任何成员变量)
----------------------
Total: 8 字节
1
2
3
4
5
#64位JVM
Object Header: 12 字节 (64位 JVM下对象头通常为12字节)
Padding: 4 字节 (填充字节,用于对齐)
Instance Data: 0 字节 (因为没有任何成员变量)
----------------------
Total: 16 字节
1
2
3
4
5
6

# 6.对象的访问定位的方式?

Student student = new Student();
1

具体是如何操作 student 对象呢?有以下两种方式

  • 使用句柄
  • 直接指针

如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息.

image-20231022230812656

如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址

image-20231022230824340

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针。而 reference 本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

# 7.方法调用的 2 种形式?

一种形式是解析,另一种是分派:

所有方法调用的目标方法在 class 文件的常量池中都有一个对应的符号引用,在类加载阶段,会将符号引用转换为直接引用,前提条件是调用之前就知道调用的版本,且在运行期间是不可变的,这种方法的调用被称为解析.

调用不同类型的方法,使用的字节码指令不同,具体如下:

  • invokestatic。用于调用静态方法。

  • invokespecial。用于调用实例构造器 init()方法、私有方法和父类中的方法。

  • invokevirtual。用于调用所有的虚方法。

  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。

  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

只要能被 invokestatic 和 invokespecial 调用的方法都能在解析阶段确定调用的版本,符合这个条件的方法有静态方法,私有方法,实例构造器,父类方法.再加上 final 修饰的方法.尽管 final 修饰的方法是通过 invokevirtual 调用的.这 5 种方法会在类加载阶段就将符号引用转化为直接引用,这些方法统称为“非虚方法”.与之相反的,被称为虚方法.

分派分为静态分派和动态分派:

所有依赖静态类型来决定执行方法版本的,统称为静态分派,最典型的就是方法重载,静态分派发生在编译阶段,这一点也是有些资料将其归为解析而不是分派的原因

动态分派–重写.动态定位到实现类的方法进行调用.

# 8.说说对 invoke 包的理解?

与反射调用的区别?

jdk1.7 开始引入 java.lang.invoke 包,这个包的主要作用是在之前的单纯依符号引用来确定调用的目标方法外,提供一种新的动态确定目标方法的机制,称为“方法句柄(method handler)”.

Reflection 和 MethodHandler 机制本质上都是在模拟方法调用,Reflection 是 java 代码级别的模拟,MethodHandler 是字节码级别的模拟.在 java.lang.invoke 包下的 MethodHandlers.LookUp(内部类)有 3 个重载的方法,findStatic,findVirtual,findSpecial3 个方法正是对应 invokeStatic,invokeVirtual,invokeSpecial 三个字节码指令.这 3 个方法是为了校验字节码权限的校验.这些底层的逻辑 Reflection 是不用去处理的.

Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandler 机制中的 java.lang.invoke.MethodHandler 对象所包含的信息多,前者是 java 代码的全面映射,包含方法签名,描述符和方法属性表中各种属性,还包含执行权限等信息,而方法句柄仅包含该方法的相关信息,通俗来讲,反射是重量级的,方法句柄是轻量级的.

# 9.新创建对象占多少内存?

64 位的 jvm,new Object()新创建的对象在 java 中占用多少内存

MarkWord 8 字节,因为 java 默认使用了 calssPointer 压缩,classpointer 4 字节,对象实例 0 字节, padding 4 字节因此是 16 字节。

如果没开启 classpointer 默认压缩,markword 8 字节,classpointer 8 字节,对象实例 0 字节,padding 0 字节也是 16 字节。

-XX:+UseCompressedOops #相当于在64位机器上运行32位
1
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
1
public class Wang_09_jol_03 {

    static MyObject myobject = new MyObject();

    public static void main(String[] args) throws InterruptedException {
        System.out.println(ClassLayout.parseInstance(myobject).toPrintable());
    }

    static class MyObject {
        int a = 1;
        float b = 1.0F;
        boolean c = true;
        String d = "hello";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

image-20231022230839704

对象头 8 字节,class 压缩关闭 8 字节,int 字段 a 占用 4 字节,flout 字段 b 占用 4 字节,boolean 字段 c 占用 1 字节,内部对齐填充 7 字节

String 类型指针占用 8 字节,一共 40 字节.

# 10.内存申请的种类?

java 一般内存申请有两种:

  • 静态内存
  • 动态内存

编译时就能够确定的内存就是静态内存,即内存是固定的,系统一次性分配,比如 int 类型变量;动态内存分配就是在程序执行时才知道要分配的存储空间大小,比如 java 对象的内存空间。根据上面我们知道,java 栈、程序计数器、本地方法栈都是线程私有的,线程生就生,线程灭就灭,栈中的栈帧随着方法的结束也会撤销,内存自然就跟着回收了。所以这几个区域的内存分配与回收是确定的,我们不需要管的。但是 java 堆和方法区则不一样,我们只有在程序运行期间才知道会创建哪些对象,所以这部分内存的分配和回收都是动态的。一般我们所说的垃圾回收也是针对的是堆和方法区。

# 12.java 异常分类?

运行时异常与非运行时异常的区别:

运行时异常:是 RuntimeException 类及其子类的异常,是非受检异常,如 NullPointerException、IndexOutOfBoundsException 等。由于这类异常要么是系统异常,无法处理,如网络问题;要么是程序逻辑错误,如空指针异常;JVM 必须停止运行以改正这种错误,所以运行时异常可以不进行处理(捕获或向上抛出,当然也可以处理),而由 JVM 自行处理。Java Runtime 会自动 catch 到程序 throw 的 RuntimeException,然后停止线程,打印异常。

非运行时异常:是 RuntimeException 以外的异常,类型上都属于 Exception 类及其子类,是受检异常。非运行时异常必须进行处理(捕获或向上抛出),如果不处理,程序将出现编译错误。一般情况下,API 中写了 throws 的 Exception 都不是 RuntimeException。

常见运行时异常:

异常类型 说明
ArithmeticException 算术错误,如被 0 除
ArrayIndexOutOfBoundsException 数组下标出界
ArrayStoreException 数组元素赋值类型不兼容
ClassCastException 非法强制转换类型
IllegalArgumentException 调用方法的参数非法
IllegalMonitorStateException 非法监控操作,如等待一个未锁定线程
IllegalStateException 环境或应用状态不正确
IllegalThreadStateException 请求操作与当前线程状态不兼容
IndexOutOfBoundsException 某些类型索引越界
NullPointerException 非法使用空引用
NumberFormatException 字符串到数字格式非法转换
SecurityException 试图违反安全性
StringIndexOutOfBoundsException 试图在字符串边界之外索引
UnsupportedOperationException 遇到不支持的操作

常见非运行时异常:

异常类 意义
ClassNotFoundException 找不到类
CloneNotSupportedException 试图克隆一个不能实现 Cloneable 接口的对象
IllegalAccessException 对一个类的访问被拒绝
InstantiationException 试图创建一个抽象类或者抽象接口的对象
InterruptedException 一个线程被另一个线程中断
NoSuchFieldException 请求的字段不存在
NoSuchMethodException 请求的方法不存在

# 13.StringBuffer 为何是可变类?

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }
}
1
2
3
4
5
6
7
8
public AbstractStringBuilder append(String str) {
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
 }
1
2
3
4
5
6
7
8
9
private void ensureCapacityInternal(int minimumCapacity) {
  // overflow-conscious code
    if (minimumCapacity - value.length > 0)
      expandCapacity(minimumCapacity);
  }

void expandCapacity(int minimumCapacity) {
        int newCapacity = value.length * 2 + 2;
        if (newCapacity - minimumCapacity < 0)
            newCapacity = minimumCapacity;
        if (newCapacity < 0) {
            if (minimumCapacity < 0) // overflow
                throw new OutOfMemoryError();
            newCapacity = Integer.MAX_VALUE;
        }
        value = Arrays.copyOf(value, newCapacity);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        if (srcBegin < 0) {
            throw new StringIndexOutOfBoundsException(srcBegin);
        }
        if (srcEnd > value.length) {
            throw new StringIndexOutOfBoundsException(srcEnd);
        }
        if (srcBegin > srcEnd) {
            throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
        }
        System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    }
1
2
3
4
5
6
7
8
9
10
11
12

StringBuffer 的 append 的实现其实是 System 类中的 arraycopy 方法实现的,这里的浅复制就是指复制引用值,与其相对应的深复制就是复制对象的内容和值;

# 14.jvm 中几种常见的 JIT 优化?

在 JVM(Java 虚拟机)中,有几种常见的即时编译(Just-In-Time,JIT)优化技术,它们用于将 Java 字节码转换成本地机器码,以提高 Java 应用程序的性能。以下是几种常见的 JIT 优化:

  1. 内联(Inlining)优化:内联是指将方法调用处直接替换为被调用方法的实际代码,避免了方法调用的开销。JIT 编译器会分析程序的执行热点,对其中的短小方法进行内联优化,减少了方法调用的开销,提高了代码执行效率。
  2. 逃逸分析(Escape Analysis)优化:逃逸分析是 JIT 编译器对对象的动态作用域进行分析,判断一个对象是否逃逸出方法的作用域。如果对象不逃逸,可以将其分配在栈上而不是堆上,避免了垃圾回收的开销,提高了程序的性能。
  3. 标量替换(Scalar Replacement)优化: 标量替换是指 JIT 编译器将一个对象拆解成其各个成员变量,并将这些成员变量分别使用标量(如基本数据类型)进行优化。这样可以避免创建和销毁对象的开销,减少了堆上的内存分配和垃圾回收压力。
  4. 循环展开(Loop Unrolling)优化:循环展开是指 JIT 编译器对循环体进行优化,将循环体中的代码重复展开多次,减少循环的迭代次数。这样可以减少循环控制的开销和分支预测错误的影响,提高了循环的执行效率。
  5. 方法内联缓存(Monomorphic/Megamorphic Inline Cache)优化:方法内联缓存是 JIT 编译器为了优化虚方法调用而采取的一种策略。它会为不同的目标类型建立缓存,并根据目标类型来直接调用对应的方法,避免了虚方法查找的开销。
  6. 常量折叠(Constant Folding)优化: 常量折叠是指 JIT 编译器在编译时将常量表达式计算得到结果,并用结果直接替换表达式。这样可以避免在运行时重复计算相同的常量表达式,提高了程序的执行效率。

# 15.逃逸分析

逃逸分析是一种在即时编译(JIT)优化过程中用于分析对象的动态作用域的技术。它的目标是确定一个对象是否"逃逸"出了方法的作用域,即是否被方法外的其他部分所引用。如果对象没有逃逸,那么 JIT 编译器可以将其优化为栈上分配而不是堆上分配,从而提高程序的性能。

逃逸分析的实现过程通常包括以下几个步骤:

  1. 标记阶段::JIT 编译器通过静态分析,标记方法中哪些对象是被分配在堆上的,并且记录下这些对象的创建点。
  2. 逃逸分析阶段: JIT 编译器在动态执行过程中进行逃逸分析,观察对象的引用情况,判断对象是否逃逸出方法的作用域。如果一个对象的引用从方法内传递到方法外,那么它就被认为是逃逸的。

逃逸分析主要有以下两种类型:

  • 全局逃逸:对象的引用逃逸到了方法外部,可能被其他线程访问,或者返回给了调用者。
  • 栈上分配:对象的引用没有逃逸,仅在方法内部可见,可以将其分配在栈上,而不需要在堆上分配。栈上分配的对象在方法返回时自动释放,不需要进行垃圾回收。

逃逸分析带来的好处:

  1. 减少堆内存分配:通过栈上分配非逃逸对象,可以减少垃圾回收的压力,降低堆内存分配的开销,提高程序的执行效率。
  2. 锁消除:对于逃逸对象,由于可能被其他线程访问,需要使用锁进行同步。但对于栈上分配的对象,由于其仅在方法内部可见,可以进行更加精确的锁消除,避免不必要的同步开销。
  3. 标量替换:逃逸分析可以帮助 JIT 编译器进行标量替换优化,将对象拆解成标量(如基本数据类型)进行优化,减少了对象访问的开销。

需要注意的是,逃逸分析并非总是带来性能提升,它会增加编译器的复杂度和开销,而且逃逸分析的准确性也受到程序的复杂性和运行环境的影响。因此,JIT 编译器通常会在逃逸分析和栈上分配之间进行权衡,根据具体的情况来决定是否进行逃逸分析和优化。

# 16.栈上分配

栈上分配(Stack Allocation)是一种内存分配的技术,通常用于编程语言中的局部变量或短暂对象。它的基本思想是将对象分配在调用栈(函数调用的栈帧)中的栈上,而不是在堆上分配内存。

以下是栈上分配的关键特点和优点:

  1. 生命周期短暂:栈上分配适用于那些生命周期非常短暂的对象,这些对象在函数执行完毕后就不再需要。由于栈上分配的对象生命周期与函数的执行周期一致,因此无需垃圾回收器来回收它们。

  2. 性能优势:相对于在堆上分配对象,栈上分配具有更快的分配和释放速度。这是因为在栈上分配只需要简单地调整栈指针,而不需要复杂的内存管理操作。

  3. 无需垃圾回收:栈上分配的对象在函数执行完毕后会自动被释放,不需要垃圾回收器来进行清理。这可以减轻垃圾回收的负担,降低了内存管理的复杂性。

  4. 局部性原理:栈上分配有助于提高内存访问的局部性原理,因为对象在栈上分配时,它们的数据通常存储在相邻的内存位置,这有助于缓存的有效利用。

栈上分配是一种优化技术,适用于特定情况下,尤其是对于生命周期短暂的对象。在编程语言和编译器中,栈上分配通常由编译器进行自动优化,程序员无需手动管理。在一些编程语言中,如 C++中的自动对象、Rust 中的栈分配等,栈上分配被广泛使用以提高性能和减少内存管理的开销。

# 17.JVM 表示浮点数

JVM(Java 虚拟机)使用 IEEE 754 标准来表示浮点数,这是一种广泛应用于计算机中的浮点数表示方法。IEEE 754 标准定义了两种精度的浮点数格式:单精度(32 位)和双精度(64 位)。Java 虚拟机中采用这两种格式来表示浮点数。

单精度浮点数(float):单精度浮点数占用 32 位,其中包含三个部分:符号位、指数位和尾数位。具体结构如下:

  • 符号位(1 位):用来表示浮点数的符号,0 表示正数,1 表示负数。
  • 指数位(8 位):用来表示浮点数的指数部分,使用移码表示,通常需要对真实指数值进行偏移,使其在表示范围内。
  • 尾数位(23 位):用来表示浮点数的尾数部分,通常为一个二进制小数。

双精度浮点数(double): 双精度浮点数占用 64 位,也包含符号位、指数位和尾数位。具体结构如下:

  • 符号位(1 位):同单精度浮点数,用来表示浮点数的符号。
  • 指数位(11 位):同样使用移码表示,表示浮点数的指数部分,对真实指数值进行偏移。
  • 尾数位(52 位):同样用来表示浮点数的尾数部分,通常为一个二进制小数。

浮点数的表示采用科学计数法的形式,即M x 2^E,其中 M 为尾数,E 为指数。根据指数的位数不同,单精度和双精度浮点数可以表示的范围和精度也不同。单精度浮点数的有效位数约为 7 位,双精度浮点数的有效位数约为 15 位,因此双精度浮点数具有更高的精度和更大的表示范围。

需要注意的是,由于浮点数的特性,它们在进行算术运算时可能会出现舍入误差,因此在比较浮点数时应当谨慎使用等号判断,而应该使用一个小的误差范围来比较。

# 18.匿名内部类只能访问 final 变量?

在 Java 中,匿名内部类(Anonymous Inner Class)是一种特殊的内部类,它没有显式的类名,通常用于创建一个只需要使用一次的简单类或接口实例。匿名内部类可以访问外部类的成员变量和方法,但对于外部类方法中的局部变量,有一个限制条件:匿名内部类只能访问被final修饰的局部变量。

这是由于 Java 编译器的限制和内部类的生命周期导致的。当创建匿名内部类时,如果允许访问非final的局部变量,那么这些变量的值可能在匿名内部类的生命周期内发生改变。这会导致不稳定的行为,因为匿名内部类的实例可以在外部类方法执行完毕后继续存在,而此时外部方法中的局部变量已经被销毁。

通过将局部变量声明为final,Java 编译器可以保证这些变量的值不会发生改变,从而避免了潜在的线程安全问题。一旦将局部变量声明为final,编译器会在匿名内部类的实例中创建一个拷贝,以保证在匿名内部类中访问的是一个不可变的值。

示例:

public void someMethod() {
    final int x = 10; // 使用final修饰局部变量

    Runnable r = new Runnable() {
        @Override
        public void run() {
            System.out.println(x); // 可以访问final变量x
        }
    };

    // 使用r执行一些操作
}
1
2
3
4
5
6
7
8
9
10
11
12

如果尝试在匿名内部类中访问非final变量,编译器会给出错误提示。但从 Java 8 开始,对于局部变量,如果它们实际上没有发生改变,而且在整个匿名内部类的生命周期中始终没有发生改变,那么 Java 编译器允许在匿名内部类中访问非final的局部变量。这种情况下,编译器会自动将这些局部变量视为final。但是,这种特性只适用于局部变量,并不适用于方法参数或实例变量。

# 19.Java 参数值传递

Java 中的参数传递是通过传值来实现的,而不是传引用。

在 Java 中,基本数据类型(如 int、float、char 等)和引用数据类型(如对象、数组等)都是按值传递的。这意味着当将一个参数传递给方法时,实际上传递的是该参数的值的副本,而不是原始变量本身。

基本数据类型(传值): 当将基本数据类型的变量作为参数传递给方法时,传递的是该变量的值的副本。在方法内对参数进行修改不会影响原始变量的值。

public void modifyInt(int num) {
    num = num + 1;
}

int x = 10;
modifyInt(x);
System.out.println(x); // 输出:10,原始变量x的值不受方法内部修改的影响
1
2
3
4
5
6
7

引用数据类型(传值): 当将引用数据类型(如对象或数组)作为参数传递给方法时,传递的是该引用的值的副本,也就是对象在堆内存中的地址。因此,方法内对参数所指向的对象进行修改,会影响原始对象的内容。但是,如果在方法内部重新分配了一个新的对象,那么原始对象的引用不会受到影响。

public void modifyArray(int[] arr) {
    arr[0] = 99;
}

int[] nums = {1, 2, 3};
modifyArray(nums);
System.out.println(nums[0]); // 输出:99,原始数组被修改
javaCopy code
public void createNewArray(int[] arr) {
    arr = new int[]{4, 5, 6}; // 在方法内部重新分配了一个新的数组
}

int[] nums = {1, 2, 3};
createNewArray(nums);
System.out.println(nums[0]); // 输出:1,原始数组引用未受影响
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

虽然在传递引用数据类型时,方法内部的修改会反映在原始对象上,但仍然可以认为这是按值传递,因为传递的是引用的值(地址)的副本。

# 20.finally 返回时机

在正常情况下,当在 try 块或 catch 块中遇到 return 语句时,finally 语句块会在方法返回之前被执行。

不论 try 块或 catch 块中是否遇到 return 语句,当执行到 finally 语句块时,它的代码都会被执行。然后,如果在 try 块中遇到了 return 语句,方法会立即返回,并且 catch 块(如果有的话)会被忽略。但在返回之前,finally 块的代码会被执行完毕。这意味着,即使在 try 块中遇到了 return 语句,finally 语句块中的代码也会得到执行。

如果没有遇到 return 语句,或者在 catch 块中遇到 return 语句,finally 语句块依然在方法返回之前执行,并在最终返回结果之前执行完毕。

这样的设计是为了确保在执行 try 块或 catch 块的过程中,能够进行一些必要的清理操作,不论是正常返回还是异常返回。finally 块通常用于释放资源或执行一些必须要在方法返回前完成的操作,从而确保程序的稳定性和正确性。

# 21.MESI 和 volatile?

既然 CPU 有缓存一致性协议(MESI),JVM 为啥还需要 volatile 关键字?

CPU 的缓存一致性协议(例如 MESI)确实帮助确保多个 CPU 核心之间的缓存一致性,但它们主要是为了解决硬件层面的缓存一致性问题,而不涉及到编程语言层面的内存可见性和同步问题。JVM 中的volatile关键字是一种在多线程编程中确保内存可见性和一致性的机制,因为 Java 是一种高级编程语言,运行在不同的硬件平台上,需要提供一致的内存模型。

下面是为什么在 JVM 中仍然需要volatile关键字的原因:

  1. Java 内存模型(Java Memory Model,JMM):JVM 定义了自己的内存模型,即 Java 内存模型(JMM),它规定了多线程程序中共享变量的访问规则。JMM 确保了在多线程环境下,共享变量的操作是可见的和有序的。volatile关键字就是用来保证这种可见性和有序性的一种方式。

  2. 缓存一致性与内存可见性的不同层次:缓存一致性协议是硬件层面的协议,它确保了不同 CPU 核心之间的缓存一致性,但它不提供高级语言层面的内存可见性和同步。volatile关键字用于确保在 Java 程序中,一个线程对共享变量的修改对其他线程是可见的,而不仅仅是在 CPU 缓存之间。

  3. 禁止指令重排序volatile关键字还可以防止编译器和处理器对代码进行一些优化,以确保指令不会被重排序。这也有助于保证多线程环境下的操作顺序是按照程序员的意图执行的。

  4. 不同硬件平台的一致性:Java 是跨平台的语言,运行在不同的硬件架构上。不同的硬件架构可能对缓存一致性有不同的实现方式,但volatile关键字提供了一种在所有平台上都一致的方式来确保内存可见性。

虽然 CPU 的缓存一致性协议有助于解决硬件层面的一致性问题,但在高级编程语言中,特别是 Java 这样的跨平台语言中,仍然需要volatile关键字来确保在多线程环境下的内存可见性和操作有序性。这样可以使程序员更容易编写正确的多线程代码,而不需要深入了解底层硬件的细节。

# 二.虚拟机执行子系统

# 1.JVM 主要包括哪四部分?

  • 类加载器(ClassLoader):在 JVM 启动时或者在类运行时将需要的 class 加载到 JVM 中。
  • 执行引擎:负责执行 class 文件中包含的字节码指令
  • 内存区(也叫运行时数据区):是在 JVM 运行的时候操作所分配的内存区.
  • 本地方法接口:主要是调用 C 或 C++实现的本地方法及返回结果。

# 2.说说运行时数据区?

  • 方法区(Method Area)
  • 堆区(Heap)
  • 虚拟机栈(VM Stack)
  • 本地方法栈(Native Method Stack)
  • 程序计数器(Program Counter Register)

image-20231022230855931

# 3.什么是程序计数器?

程序计数器(PC Register):程序计数器(Program CounterRegister)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

# 4.什么是 java 虚拟机栈?

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(StackFrame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

image-20231022230913465

# 5.说说局部变量表?

存储局部变量表: 局部变量表是 JVM 中的一块内存区域,用于存储方法执行过程中所需的局部变量。局部变量包括方法的参数和方法内部定义的局部变量。在方法执行时,JVM 会根据方法的签名和方法体中定义的局部变量,在局部变量表中为这些变量分配内存空间。局部变量表中的变量可以是基本数据类型和对象引用。

局部变量表作用:

  • 存储方法的参数和局部变量,提供方法执行时的临时工作空间。
  • 在方法调用时,用于传递参数和保存返回值。
  • 在方法内部,可以通过索引访问局部变量,进行数据操作和计算。

栈帧中变量的存储:

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的变量,在编译为 class 文件时,就在方法的 code 属性,设置了 max_locals 数据项中确定局部变量表的大小.

局部变量表是以变量槽为单位的,java 虚拟机规范并没有指出变量槽的具体占用空间,只是每个变量槽都应该能存放一个 boolean,byte,char,short,int,float,reference 和 returnAddress 类型的数据.这 8 种数据类型都是以 32 位或者更小的内存来存储的.但这种描述和每个变量槽用 32 位来存储是有差别的,它允许变量槽随着环境的变化而变化.

对于 64 位的类型数据,java 虚拟机会以高位对其的方式分配 2 个连续的变量槽空间.java 语言中 64 位只有 long 和 double 类型,由于局部变量表是在线程栈中创建的,线程私有,不会出现线程安全问题.

public int calc(){
  int a=100;
  int b=200;
  int c = 300;
  return (a + b) * c;
}
1
2
3
4
5
6
public calc()I
    BIPUSH 100
    ISTORE 1
    SIPUSH 200
    ISTORE 2
    SIPUSH 300
    ISTORE 3
    ILOAD 1
    ILOAD 2
    IADD
    ILOAD 3
    IMUL
    IRETURN
    MAXSTACK = 2
    MAXLOCALS = 4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

image-20230727143734257

istore 指令, _1 代表存入变量槽 1,是将局部变量存入局部变量表。槽位 0 代表了 this,所以看不到 0,如果使用到了 this,则会 ALOAD 0

栈桢的局部变量表是如何定位变量的:

java 虚拟机通过索引定位的方式来使用局部变量表。索引值从 0 开始,至局部变量表变量槽最大的数量。如果是访问的 32 位数据类型的变量,索引 n 就代表了第 n 个变量槽,如果是 64 位,则访问的是第 n 和 n+1 两个变量槽。对于 64 位的 2 个变量槽,不允许任何方式访问其中一个变量槽。如果出现这种情况,在类加载校验阶段会抛出异常。

是如何完成实参到形参传递的:

当一个方法被调用时,java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法,那局部变量表的第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过 this 关键字来访问到这个隐含的参数。其余参数则按照顺序在变量槽中排列,参数表分配完后,在根据方法体内部定义的变量和作用域来分配其余的变量槽。

# 6.说说操作数栈?

操作数栈:操作数栈是 JVM 中的另一块内存区域,用于存储方法执行过程中的操作数和中间结果。JVM 采用后缀表达式(逆波兰表达式)来执行操作数栈中的操作。在执行方法时,JVM 会根据字节码指令,从操作数栈中弹出操作数,进行计算,并将结果再次压入操作数栈中。

作用:

  • 存储方法执行过程中的操作数和中间结果。
  • 在方法执行时,提供了一种轻量级的数据交换和计算方式,用于执行方法体内的运算。

# 7.说说动态链接?

动态链接(Dynamic Linking):动态链接是指在方法的调用过程中,将方法所在的类与方法的符号引用进行解析,得到方法的直接引用(方法在内存中的真实地址)。这个过程是在运行期间进行的,而不是在编译期间确定的。Java 虚拟机使用动态链接来支持多态性,以及在运行时动态绑定方法。

作用:

  • 支持多态性:允许不同的子类调用其父类中定义的同名方法,实现方法的动态绑定。
  • 提高程序的灵活性:在运行时才进行链接,可以在后续版本中动态替换类和方法的实现。

# 8.说说方法出口?

方法出口:方法出口是指方法执行结束后将返回结果的地址返回给调用者的指令。在 Java 虚拟机的字节码指令中,return指令用于方法的正常返回,athrow指令用于方法抛出异常。这些指令将方法的执行结果或异常传递给调用者,并根据调用者的处理逻辑来进行相应的处理。

作用:

  • 控制方法的返回和异常抛出的行为。
  • 将方法执行结果或异常传递给调用者,实现方法的正常返回和异常处理。

# 9.两个栈桢是否会有重叠?

2 个不同栈桢作为不同方法的虚拟机栈的元素,是完全独立的。但是大多数的虚拟机实现里会进行一些优化处理。会存在共享存储空间的部分,比如下面栈桢的操作数栈与上面栈桢的局部变量表重叠在一起,这样做不仅节约了空间,更重要的是在进行方法调用时就可以直接共用一部分数据,不用再额外进行参数的复制传递。

image-20230727144225614

# 10.详细说说方法区?

方法区(Method Area)是 Java 虚拟机(JVM)的一部分,用于存储类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。它是 Java 虚拟机规范中定义的一种内存区域,用于支持 JVM 的运行时环境和 Java 程序的执行。

以下是关于方法区的详细解释:

  1. 定义: 方法区是 JVM 中的一块内存区域,其在 JVM 启动时就被创建,并且随着 JVM 的关闭而销毁。在 Java 虚拟机规范中,方法区被定义为存储类和接口的元数据、运行时常量池、字段数据、方法数据、构造函数和普通方法的字节码以及一些特定于方法区的数据。
  2. 存储内容: 方法区主要存储以下内容:
    • 类信息:每个类的完整结构信息,包括类名、父类、实现的接口、字段、方法等。
    • 运行时常量池:每个类的常量池,包含编译时生成的字面量常量和符号引用。
    • 静态变量:类级别的静态变量,也称为类变量。
    • 即时编译器编译后的代码:当 JVM 执行某个方法时,可能会将该方法的字节码编译成本地机器代码,并将其存储在方法区中。
    • 方法字节码:包含每个方法的操作码(opcode)和操作数,以实现方法的功能。
  3. 线程共享: 方法区是所有线程共享的内存区域,与线程无关。在多线程的情况下,多个线程可以同时访问方法区。
  4. OutOfMemoryError: 方法区可以发生 Out of Memory 错误。当加载过多的类或者动态生成大量类时,方法区可能会耗尽内存。
  5. PermGen(永久代)与元空间(Metaspace): 在 Java 7 之前,方法区被称为"永久代"(PermGen),但是由于永久代容易导致内存溢出问题,Java 8 中将方法区的实现改为"元空间"(Metaspace),并且将其移到本地内存。元空间不再有固定的大小,而是使用本地内存,受限于操作系统的可用内存。
  6. 自动内存管理: 方法区的内存由 JVM 自动管理。垃圾收集器会负责回收不再使用的类、常量和静态变量,并且会在需要时进行内存扩展。

# 11.符号引用和直接引用?

在 Java 虚拟机(JVM)中,符号引用(Symbolic Reference)和直接引用(Direct Reference)是两种不同类型的引用,用于在运行时定位和访问类、字段、方法等。

符号引用(Symbolic Reference)::符号引用是一种用于描述所引用目标的符号名称的引用。在 Java 源代码中,类、方法和字段都使用符号引用进行引用,而不涉及具体的内存地址或偏移量。符号引用是在编译期和链接期间使用的,它们保持独立于虚拟机的内存布局,使得 Java 程序具有平台无关性。符号引用通常包括以下信息:

  • 类符号引用: 类的全限定名(包名+类名)。
  • 方法符号引用: 类符号引用 + 方法名 + 方法描述符(描述了方法参数类型和返回值类型)。
  • 字段符号引用: 类符号引用 + 字段名 + 字段描述符(描述了字段类型)。

由于符号引用并不直接指向内存中的实际位置,所以在运行时需要解析成直接引用才能定位实际的数据。

直接引用(Direct Reference): 直接引用是指向具体内存位置的指针、句柄或偏移量,用于直接访问类、字段、方法等在内存中的实际位置。直接引用是在虚拟机运行时才产生的,通过解析符号引用得到。在 JVM 的方法区内存结构中,方法表(Method Table)和字段表(Field Table)都包含直接引用。

  • 方法表中的直接引用: 方法表中的每个项对应一个类中的方法,其中包含指向该方法实际代码的直接引用,使得虚拟机可以直接定位并执行该方法的字节码。
  • 字段表中的直接引用: 字段表中的每个项对应一个类中的字段,其中包含指向该字段实际数据的直接引用,使得虚拟机可以直接访问和操作该字段的值。

直接引用是在虚拟机运行时才解析的,因此可以根据具体的内存布局和对象结构来定位实际的数据。

总结:符号引用是一种独立于具体内存布局的引用方式,在编译和链接期间使用,用于描述类、字段和方法的符号信息。而直接引用是运行时根据符号引用解析得到的具体内存地址或指针,用于在 JVM 运行时定位实际的类、字段和方法。直接引用的使用使得 JVM 具有更高的运行效率和更好的灵活性。

# 12.说说运行时常量池?

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

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

JDK1.6 中,JDK 1.6 方法区中的运行时常量池中包括了字符串常量池:

image-20230727141607057

JDK1.7 中,字符串常量池从方法区移动到了堆中:

image-20230727141635776

JDK1.8 中,字符串常量池在堆中,运行时常量池,类常量池还在方法区,方法区变为元空间:

image-20230727141709603

public class GcDemo {

    public static void main(String [] args) {
        String str = new String("lonely")+new String("wolf");
        System.out.println(str == str.intern());
    }
}
1
2
3
4
5
6
7

这段代码在 jdk1.6 中打印 false,在 jdk1.7 和 jdk1.8 中打印 true。 关于 intern()方法:

  • jdk1.6:调用 String.intern()方法,会先去检查常量池中是否存在该字符串,如果不存在,则会在方法区中创建一个字符串,而 new String()创建的字符串在堆中,两个字符串的地址当然不相等。
  • jdk1.8:字符串常量池从方法区的运行时常量池移到了堆中,调用 String.intern()方法,首先会检查常量池是否存在,如果不存在,那么就会创建一个常量,并将引用指向堆,也就是说不会再重新创建一个字符串对象了,两者都会指向堆中的对象,所以返回 true。

执行 String.intern()如果在 1.7 和 1.8 中会检查字符串常量池,发现没有 lonelywolf 的字符串,所以会在字符串常量池创建一个,指向堆中的字符串。 但是在 jdk1.6 中不会指向堆,会重新创建一个 lonelywolf 的字符串放到字符串常量池,所以才会产生不同的结果

# 13.说说全局字符串池?

全局字符串池(Global String Pool),也称为字符串常量池(String Constant Pool),是 Java 中一种特殊的字符串缓存机制。它位于方法区内存中,用于存储在编译时期和运行时期遇到的字符串常量,以及通过 String 类的intern()方法手动添加到池中的字符串。

以下是关于全局字符串池的一些重要特点和解释:

  1. 字符串常量池的目的: Java 为了提高性能和节省内存,在全局字符串池中保存一份唯一的字符串实例。当程序中创建多个相同内容的字符串时,实际上会共享一个对象,从而减少内存占用。
  2. 字符串常量池位置: 在 Java 7 及之前,全局字符串池位于永久代(PermGen)内存中。而在 Java 8 及之后,随着永久代的移除,字符串常量池被移到了元空间(Metaspace)中。
  3. 字符串常量池特性:
    • 字符串常量池中的字符串是不可变的(Immutable),一旦创建就不可修改。这是通过在 String 类中使用 final 关键字来实现的。
    • 当通过字面值(例如:String str = "Hello";)创建字符串对象时,JVM 会首先检查全局字符串池中是否已经存在相同内容的字符串,如果存在,则直接返回引用。否则,创建一个新的字符串对象,并将其添加到字符串常量池中。
  4. String 类的 intern()方法:
    • intern()方法是 String 类的一个实例方法,它用于将字符串添加到全局字符串池中,并返回池中对应的引用。
    • 如果全局字符串池中已经存在相同内容的字符串,intern()方法将返回池中的引用;如果不存在,则将当前字符串添加到池中并返回对应引用。
    • intern()方法在某些情况下可以用于节省内存和加速字符串比较操作,但过度使用它也可能会增加全局字符串池的负担。
  5. 字符串拼接: 字符串拼接操作在 Java 中经常用到。在 Java 5 之前,使用+进行字符串拼接会导致大量临时对象产生,影响性能。但在 Java 5 之后,JVM 对字符串拼接做了优化,使用 StringBuilder 来处理,避免了临时对象的产生。

# 14.说说 class 文件常量池?

class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 字面量就是我们所说的常量概念,如文本字符串、被声明为 final 的常量值等。 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可.

一般包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

Java 常量池中可能出现的 11 种不同表结构数据以及它们对应的标志位(1 字节):

常量类型标志位 常量类型 表结构数据
1 UTF-8 字符串内容
3 Integer 4 字节整数值
4 Float 4 字节浮点数值
5 Long 8 字节长整数值
6 Double 8 字节双精度浮点数值
7 Class Reference 指向一个类或接口的全限定名字符串(UTF-8 索引)
8 String Reference 指向一个字符串常量的值(UTF-8 索引)
9 Field Reference 指向一个字段的描述符(Class Reference 索引 + NameAndType 索引)
10 Method Reference 指向一个类中的方法(Class Reference 索引 + NameAndType 索引)
11 Interface Method Ref. 指向一个接口中的方法(Class Reference 索引 + NameAndType 索引)
12 Name and Type Descriptor 描述字段或方法的名称和类型(Name 索引 + Descriptor 索引)

# 15.三个常量池之间的关系?

示例1:

public class HelloWorld {
  public static void main(String []args) {
    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
  }
1
2
3
4
5
6
7
8
9
10
11

上面程序的首先经过编译之后,在该类的 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”的引用值

# 16.String 相加产生对象

以下代码会产生几个对象:

new String("lonely")+new String("wolf")
1

在表达式 new String("lonely") + new String("wolf") 中,会产生六个对象:

  1. "lonely" 字符串常量:编译时,会将字符串常量放入编译时常量池中。
  2. "wolf" 字符串常量:编译时,同样会将字符串常量放入编译时常量池中。
  3. new String("lonely") 对象:在运行时,new String("lonely") 会创建一个新的 String 对象,并在堆上分配内存。这是因为new关键字会在堆上创建新的对象,即使这个对象的值在编译时已经存在于常量池中。
  4. new String("wolf") 对象:同样地,在运行时,new String("wolf") 会创建另一个新的 String 对象,并在堆上分配内存。
  5. "lonelywolf" 字符串常量:由于字符串的拼接操作使用了+运算符,会生成一个新的字符串对象。在这里,两个new String对象会被连接起来形成一个新的字符串常量"lonelywolf"
  6. 还有一个特殊的是 new StringBuilder 对象

综上所述,表达式 new String("lonely") + new String("wolf") 会产生五个对象:两个字符串常量"lonely""wolf",以及三个 String 对象。请注意,字符串常量是存储在全局字符串池中的,而new String对象是在堆上创建的。由于字符串的不可变性,它们在运行时是不可修改的。

# 17.java 堆的结构

  • 新⽣代通常占 JVM 堆内存的 1/3,因为新⽣代存储都是新创建的对象,⽐较⼩的对象,⽽⽼年代存的都是⽐较⼤的,活的久的 对象,所以⽼年代占 JVM 堆内存较⼤;
  • 新⽣代⾥的 Eden 区通常占年轻代的 4/5,两个 Survivor 分别占新⽣代的 1/10。因为 Survivor 中存储的是 GC 之后幸存的对象,实际上只有很少⼀部分会幸存,所以 Survivor 占的⽐例⽐较⼩。

image-20231022230943250

如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数-Xmx 和-Xms 设定)。如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

# 18.什么是直接内存?

直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区场或。但是这部分内存也被频繁地使用,而且也可能导致 Out of MemoryError 异常出现。

在 JDK1.4 中新加入了 NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 1/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

直接内存大小不受堆内存限制,但受本机总内存限制.

# 19.Integer 缓存区域

在 Java 虚拟机(JVM)中,Integer 常量池是一个存储整数常量的缓存区域。在 Java 中,为了提高性能和减少内存占用,对于整数常量,JVM 会维护一个常量池,以便重复使用相同的常量值而不是每次都创建新的对象。

对于整数常量,Java 中的自动装箱(autoboxing)和自动拆箱(autounboxing)功能也会影响常量池的使用。当你使用Integer类创建整数对象时,JVM 会尽量重用已经存在的对象。

在 Java 中,整数常量池的范围通常是在 -128 到 127 之间。这个范围内的整数对象会被缓存,而超出这个范围的整数对象则会被重新创建。

以下是一个例子:

Integer a = 127;
Integer b = 127;

System.out.println(a == b); // 输出 true,因为在常量池中会重用相同的整数对象

Integer c = 128;
Integer d = 128;

System.out.println(c == d); // 输出 false,因为超出了常量池范围,会创建新的对象
1
2
3
4
5
6
7
8
9

需要注意的是,具体的实现可能会有所不同,不同的 JVM 厂商可能会有不同的优化策略。上述范围和行为是根据 Java 语言规范来描述的一种典型情况。

# 三.类加载

# 1.jvm 类加载的整体流程?

  1. 通过一个类的全限定名来获取此类的二进制字节流(加载阶段)
  2. Class 文件的格式验证(连接–>验证–>文件格式验证)
  3. 将这个字节流所代表的的静态存储(class 文件本身)结构转化为方法区的运行时数据结构(加载阶段)
  4. 在内存(堆内存)中生成这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口(加载阶段)
  5. 元数据验证(连接–>验证–>元数据验证)
  6. 字节码验证(连接–>验证–>字节码验证)
  7. 准备阶段,赋初始值(连接–>准备)
  8. 解析/符号引用验证(连接–>解析–>连接–>验证–>符号引用验证)
  9. 初始化(初始化)

image-20231022231159149

# 2.加载阶段 JVM 具体进行了什么操作?

加载阶段主要完成了 3 件事情

  1. 通过一个类的全限定名生成类的二进制字节流
  2. 将这个二进制字节流的静态存储结构转化为在方法区中虚拟机支持的运行时数据结构(将虚拟机外部的字节流转化为虚拟机所设定的格式存储在方法区中)
  3. 在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口

相对于类加载的过程,非数组类型的加载阶段(准确的说,是获取类的二进制字节流的动作)是开发人员可控性最强的阶段,可以使用虚拟机内置的类加载器来完成,也可以由用户自定义的类加载器来完成,开发人员通过自定义的类加载器去控制字节流的获取方式(重写一个类的类加载器的 findClass 方法或者 loadClass 方法),根据需求获取运行代码的动态性.

# 3.JVM 加载数组和加载类的区别?

对于数组而言,情况有所不同,数组类本身不通过类加载器创建,它是由 java 虚拟机直接在内存中动态构建出来的,但是数组跟类加载器还是密切关联的,因为数组类的元素类型最终还是需要通过类加载器加载完成.

如果数组的元素类型是引用类型,那么遵循 JVM 的加载过程,去加载这个组件类型.数组类将被标识在加载该组件类型的类加器的类命名空间上.(这一点很重要,一个类必须与类加载器一起确定唯一性)

如果数组类的组件类型不是引用类型(比如 int[]),java 虚拟机会把数组类标记为与启动类加载器关联

数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类型的可访问性默认是 public,可被所有的接口和类访问到.

# 4.验证阶段 JVM 主要做了什么?

验证是连接的第一步.这一阶段的主要的目的是确保 class 的文件的二进制字节流中包含的信息符合 java 虚拟机规范的全部约束要求,保证这些信息被当做代码运行后,不会对虚拟机自身造成危害.

验证阶段是非常重要的,从代码量和耗费的执行性能的角度上来讲,验证阶段的工作量在虚拟机整个类加载过程中占比相当大.

验证主要分为四个验证: 文件格式验证,元数据验证,字节码验证,符号引用验证

文件格式验证:

  • 是否以魔数开头
  • 主次版本号是否在当前虚拟机支持的范围内
  • 常量池中是否含有不被支持的常量类型(检查常量 tag 标志)
  • 指向常量的各种索引值中是否含有指向不存在的常量或者不符合类型的常量
  • constant_utf8_info 型的常量是否存在不符合 utf8 编码的数据
  • class 文件中各个部分以及文件本身是否有被删除的或者附加的其他信息

这个阶段是基于二进制字节流进行的,只有通过这个验证,二进制字节流才会到达方法区进行存储,后面的三个验证也是基于方法区的存储结构,不会直接读取字节流了

元数据验证:

  • 这个类是否包含父类(除了 java.lang.object 外,所有类都要有父类)
  • 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
  • 类中的字段,方法是否与父类中产生矛盾,(例如覆盖了父类的 final 字段,或者出现了不符合规范的方法重载)

字节码验证:

这一阶段是验证阶段最为复杂的阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的.这阶段主要是校验 class 文件的 code 属性.

  • 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用的时候按 long 类型来加载入本地变量表中”这样的情况
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
  • 保证方法体之内的类型转换都是有效的,比如,可以把一个子类赋值给父类数据类型,这是安全的,但是把父类赋值给子类数据类型,甚至是毫无关系的数据类型,这是危险的,不合法的.

由于数据流和控制流的高度复杂性,为了避免过多的时间消耗,在 jdk1.6 之后的 javac 编译和 java 虚拟机里进行了一项联合优化,把尽可能多的校验移到 javac 编译器里进行.具体做法是给方法体 code 属性的属性表中添加一项 StackMapTable 的新属性,这个属性描述了方法体所有的基本块,开始时本地变量表和操作数栈应有的状态,在字节码验证期间,java 虚拟机就不需要根据程序推导了,只需要检查 StackMapTable 的记录是否合法即可.这样字节码验证的类型推导就变成了类型检查,从而节省了大量的校验时间.

符号引用验证:

最后一个验证阶段发生在虚拟机将符号引用转化为直接引用的时候,这个过程在连接的第三个阶段解析阶段发生.

符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗讲就是验证该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等资源.

  • 符号引用中通过字符串的全限定名能否找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
  • 符号引用中的类,字段,方法的可访问性是否能被当前类访问

符号引用的目的是确保解析行为能够正常执行,如果无法通过符号引用验证,java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError 等

# 5.类加载器连接的准备阶段做了什么?

准备阶段是正式为类中定义的变量(静态变量,被 static 修饰的变量),分配内存并设置初始值的过程

这些变量使用的空间应当在方法区中进行分配,在 jdk1.7 及之前,hotspot 使用永久代来实现方法区,在 jdk1.8 之后,类变量会随着 class 对象一起存放在堆中.

准备阶段进行内存分配的只有类变量不包含实例变量,实例变量需要在对象实例化后在堆中分配,通常情况下,初始值一般为零值.这些内存都将在方法区内分配内存,不包含实例变量,实例变量在堆内存中,而且实例变量是在对象初始化时才赋值

public static int value=123;
1

准备阶段初始值是 0 不是 123,因为在此时还没执行任务 java 方法,而把 value 赋值为 123 是在 putstatic 指令是程序被编译后,存放在类构造器的 clinit 方法中,赋值为 123 在类的初始化阶段.上面说的通常情况是初始值为零值,特殊情况下被 final,static 修饰时,直接赋予ConstantValue属性值.比如 final static String

# 6.类加载器连接的解析阶段做了什么?

解析阶段是将常量池内的符号引用转化为直接引用的过程

符号引用:用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以定位到目标即可

直接引用:直接引用可以直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄.如果有了直接引用,那么引用的目标在虚拟机的内存中一定存在.

并未明确指出解析的具体时间,可以是在加载时就解析常量池中的符号引用,或者是等到第一个符号引用将要被使用前解析它,这个 java 虚拟机规范中没有明确说明.对同一个符号引用进行多次解析是存在的,虚拟机可以对第一次解析的结果进行缓存,譬如运行时直接引用常量池中的状态,并把常量标示为已解析状态,从而避免了重复动作.

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄,调用点限定符等 7 类符号引用进行,分别对应了常量池中的

  • constant_class_info
  • constant_firldref_info
  • constant_methodref_info
  • constant_interfacemethodref_info
  • constant_methodtype_info
  • constant_methodhandle_info
  • constant_dynamic_info
  • constant_invokedyanmic_info

一共 8 种常量类型.

# 7.类加载器初始化阶段做了什么?

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 clinit()方法的过程。clinit()并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物。

# 8.说说你对 clinit 方法的理解?

clinit 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的.编译器收集的顺序是由源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量发,在前面的静态语句块可以赋值,但是不能访问.

public class Jvm_0999_static {
    static {
        i = 0;//给变量赋值可以正常编译通过
        System.out.print(i);//这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}
1
2
3
4
5
6
7

clinit 方法与类的构造函数不同,它不需要显式的调用父类构造器,java 虚拟机会保证在子类的 clinit 方法执行之前,父类的 clinit 方法已经执行完毕,因此在 java 虚拟机中第一个被执行的 clinit 方法的类型肯定是 java.lang.object 类型

public class Jvm_09999_Parent {
  //依次为A=0,A=1,A=2
  public static int A = 1;

  static {
    A = 2;
  }

  static class Sub extends Jvm_09999_Parent {
    public static int B = A;
  }

  public static void main(String[] args) {
    System.out.println(Sub.B);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

clinit 方法对于类或者接口不是必须的,如果一个类没有静态语句块,也没有对变量的赋值行为,那么编译器就不会生成这个类的 clinit 方法.

接口中不能使用静态语句块,但是可以有变量赋值操作,因此接口和类一样也会生成 clinit 方法,但是接口和类不同的是,接口的 clinit 方法不需要先执行父类的 clinit 方法,因为只有当父接口中被定义的接口被使用时,父接口才会初始化.接口的实现类在初始化时一样不会执行 clinit 方法.

java 虚拟机必须保证一个类的 clinit 方法在多环境中被正确的同步加锁.如果是多线程去初始化一个类,那么只会有一个线程去执行这个类的 clinit 方法,其他线程需要阻塞等待,直到活动线程执行完 clinit 方法.其他线程被唤醒后不会继续执行 clinit 方法.

# 9.JVM 会立即对类进行初始化?

对于初始化阶段,java 虚拟机规范严格规定有且只有 6 种情况必须对类进行“初始化”

  1. 遇到 new,getstatic,putstatic,invokestatic这四条指令时,如果类型没有进行初始化,则需要触发其初始化阶段,这四条指令的 java 场景
    • 使用 new 关键字实例化对象的时候
    • 读取或设置一个类型的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候
    • 调用一个类型的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有初始化,则需要先触发其初始化
  3. 当初始化类时,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
  4. 当虚拟机宕机时,用户需要指定一个主类,(包含 main 方法的那个类),虚拟机会先初始化这个类
  5. 当使用 jdk1.7 新加入的动态语言支持时,如果一个 java.lang.invoke.methodhandle 实例最后的解析结果为 ref_getstatic,ref_putstatic,ref_invokestatic,``ref_newinvokespecial` 四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化.
  6. 当一个接口中定义了默认方法,如果这个类的实现类发生了初始化,这个接口要在其之前被初始化.
public class SuperClass {
  static {
    System.out.println("SuperClass init");
  }

  public static int value = 123;
}

public class SubClass extends SuperClass {
  static {
    System.out.println("SubClass init");
  }
}

public class NotInitialization {

  public static void main(String[] args){
    System.out.println(SubClass.value);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

上述代码只会执行“SuperClass init”,对于静态字段,只有直接定义这个字段的类才会被初始化,因此只会触发父类的初始化,不会触发子类的初始化.

public class NotInitialization {

  public static void main(String[] args){
    SuperClass[] sc = new SuperClass[10];
  }
}
1
2
3
4
5
6

通过数组引用的类,不会触发此类的初始化.

public class ConstClass {
  static {
    System.out.println("ConstClass init");
  }
  public static final String HELLO_WORD = "hello world";
}

public class NotInitialization {
  public static void main(String[] args){
    System.out.println(ConstClass.HELLO_WORD);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

final static 常量不会触发类的初始化编译器就放入到属性表的 ConstantValue

# 10.不同的类加载器对 instanceof 影响?

public class ClassLoaderTest {
  public static void main(String[] args) throws Exception {
    ClassLoader myLoader = new ClassLoader(){
      @Override
      public Class<?> loadClass(String name) throws ClassNotFoundException {
        try {
          String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
          InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
          if (resourceAsStream == null){
            return super.loadClass(name);
          }
          byte[] bytes = new byte[resourceAsStream.available()];
          resourceAsStream.read(bytes);
          return defineClass(name, bytes,0, bytes.length);
        } catch (IOException e){
          throw new ClassNotFoundException();
        }
      }
    };
    Object o = myLoader.loadClass("com.xiaofei.antbasic.demo4.ClassLoaderTest").newInstance();
    System.out.println(o.getClass());
    System.out.println(o instanceof com.xiaofei.antbasic.demo4.ClassLoaderTest);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class com.xiaofei.antbasic.demo4.ClassLoaderTest
false
1
2

虚拟机中存在了 2 个 ClassLoaderTest 类,一个是虚拟机的类加载器加载的,另一个是我们自定义的类加载器加载的,即使这 2 个类源自同一个 class 文件,被同一个 java 虚拟机加载,但是类加载器不同,那么 2 个类必不相等.

# 11.说下双亲委派模型?

image-20231022231217920

启动类加载器:负责加载存在 java_home/lib 下的,或者是被-xbootclasspath 参数所指定的路径中存放,而且能被虚拟机所识别的(按照文件名称识别,如 rt.jar,tools.jar)类库加载到虚拟机内存中.启动类加载器无法被用户直接引用,如果需要委派加载请求给启动类加载器,直接使用 null 代替即可.

扩展类加载器:它负责加载 java_home\lib\ext 目录中的,或者被 java.ext.dirs 所指定路径中的类库.

应用程序类加载器:这个类加载器是 ClassLoader 类中 getSystem-ClassLoader 方法的返回值.所以也称为系统类加载器.它负责加载类路径 classpath 上所有的类库,如果没有自定义类加载器,应用程序类加载器就是默认的类加载器.

双亲委派模型要求,除了顶层的类加载器除外,其他的类加载器都要有父类加载器,这里的父子不是继承关系,而是组合关系来复用父加载器的代码.

双亲委派工作流程:

当一个类加载器收到类加载的请求,首先不会自己去加载此类,而是请求父类去加载这个类,层层如此,如果父类加载器不能加载此类(在搜索范围内没有找到需要的类),子类才会去尝试加载.

使用双亲委派的好处是,java 中的类随着类加载器具备了一种优先级的层次关系,例如 java.lang.object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都委派给顶层的启动类加载器去加载,因此 object 在各个类加载器环境中都能保证是同一个类.

ClassLoader 源码

protected Class<?> loadClass(String name, boolean resolve)
  throws ClassNotFoundException
{
  synchronized (getClassLoadingLock(name)){
    // First, check if the class has already been loaded
    Class<?> c = findLoadedClass(name);
    if (c == null){
      long t0 = System.nanoTime();
      try {
        if (parent != null){
          c = parent.loadClass(name, false);
        } else {
          c = findBootstrapClassOrNull(name);
        }
      } catch (ClassNotFoundException e){
        // ClassNotFoundException thrown if class not found
        // from the non-null parent class loader
      }

      if (c == null){
        // If still not found, then invoke findClass in order
        // to find the class.
        long t1 = System.nanoTime();
        c = findClass(name);

        // this is the defining class loader; record the stats
        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
        sun.misc.PerfCounter.getFindClasses().increment();
      }
    }
    if (resolve){
      resolveClass(c);
    }
    return c;
  }
}
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

双亲委派的破坏:自定义类加载器,然后重写 loadClass()方法

# 12.为什么 Tomcat 打破双亲委派?

tomcat 的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String 等),各个 web 应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给 commonClassLoader 走双亲委托。

image-20230728002638535

一个 web 容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。

Web应用程序类加载器在加载 Web 应用程序的类时,不会使用传统的双亲委派机制。相反,它首先尝试从 Web 应用程序的类路径加载类,如果找不到则才委派给父类加载器。这使得每个 Web 应用程序都可以有自己的类加载空间,从而避免了类加载冲突

打破双亲委派的真正目的是,当 classpath、catalina.properties 中 common.loader 对应的路径、web 应用/WEB-INF,下有同样的 jar 包时,优先使用/WEB-INF 下的 jar 包.

tomcat 不遵循双亲委派机制,只是自定义的 classLoader 顺序不同,但顶层还是相同的,还是要去顶层请求 classloader.

# 13.类元素加载过程

如果有两个类 A 和 B,B 继承了 A,A 和 B 里面都有静态方法和静态变量,那么它的是怎么样的呢?

1、父类的静态变量

2、父类的静态代码块

3、子类的静态变量

4、子类的静态代码块

5、父类的非静态变量

6、父类的非静态代码块

7、父类的构造方法

8、子类的非静态变量

9、子类的非静态代码块

10、子类的构造方法

# 14.new HashMap 过程

在 Java 中使用new HashMap<>()来创建一个新的HashMap对象时,涉及到多个 JVM(Java 虚拟机)的核心概念和内部机制,包括但不限于:

  1. 类加载(Class Loading):JVM 首先需要加载HashMap类,如果它还没有被加载。类加载是通过类加载器(Class Loaders)完成的。

  2. 内存分配new关键字导致 JVM 在堆内存(Heap)中分配空间以存储新创建的HashMap对象。

  3. 构造函数调用HashMap的构造函数会被调用,用于初始化新创建的对象。这可能涉及到初始化其内部的数组,设置加载因子、容量等。

  4. 垃圾收集(Garbage Collection):当HashMap对象不再被引用时,它将成为垃圾收集的目标。JVM 的垃圾收集器会在适当的时候释放这块内存。

  5. 动态分派(Dynamic Dispatch):如果你使用的是Map接口来接收new HashMap<>()返回的对象(例如,Map<K, V> map = new HashMap<>()),则涉及到动态方法分派。这是多态的一部分。

  6. 泛型(Generics):如果你在创建HashMap对象时使用了泛型(例如,HashMap<String, Integer>),则涉及到类型擦除和桥接方法的概念,尽管这主要在编译时处理。

  7. JIT 编译(Just-In-Time Compilation):JVM 可能会将经常运行的字节码动态地编译为本地机器代码,以提高执行速度。这涉及到HashMap相关方法和构造函数的调用。

  8. 数据结构HashMap内部使用数组和链表(或红黑树,在 Java 8 及以后版本中)来存储键-值对。了解这些数据结构有助于更好地理解HashMap的性能特性。

  9. 并发(Concurrency):虽然HashMap本身不是线程安全的,但在多线程环境中使用它时需要考虑到 JVM 的内存模型和同步。

  10. 常量池(Constant Pool):字面量(如字符串键)可能会存储在 JVM 的常量池中。

  11. 字节码(Bytecode)new操作符和构造函数调用在字节码级别有对应的操作和指令(比如 NEWINVOKESPECIAL 字节码指令)。

了解这些概念和内部机制不仅有助于更好地理解HashMap是如何工作的,还有助于深入了解 JVM 的运行机制。

# 四.异常机制

# 1.异常机制的过程

Java 的异常处理机制允许程序在运行时检测和处理错误或异常情况,以提高程序的可靠性和健壮性。以下是 Java 异常处理机制的基本过程:

  1. 抛出异常(Throwing Exceptions): 当在程序执行过程中发生错误或异常情况时,可以使用throw语句手动抛出一个异常对象。异常对象通常是 Exception 类的子类的实例,它包含有关错误情况的详细信息。

  2. 捕获异常(Catching Exceptions): 使用try语句块可以将可能引发异常的代码包围起来。然后可以使用一个或多个catch块来捕获并处理特定类型的异常。每个catch块可以处理一个特定类型的异常,并提供相应的处理逻辑。

  3. 处理异常(Handling Exceptions): 在catch块中,可以编写处理异常的代码,例如记录错误日志、显示错误消息、修复问题等。处理异常的方式可以根据程序的需要而定。

  4. 清理资源(Cleaning Up Resources): 使用finally块可以确保在无论是否发生异常都会执行特定的代码块,例如释放资源、关闭文件或网络连接等。finally块中的代码将始终执行,即使在try块或catch块中发生了return语句。

以下是一个简单的 Java 异常处理的示例代码:

public class ExceptionExample {
    public static void main(String[] args) {
        try {
            int result = divide(10, 0);
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Error: " + e.getMessage());
        } finally {
            System.out.println("Cleanup code here");
        }
    }

    public static int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return a / b;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在上面的代码中,divide方法会抛出一个 ArithmeticException 异常,当尝试除以零时。在main方法中,使用try块捕获了这个异常,并在catch块中进行处理,最后在finally块中执行清理操作。

请注意,Java 中的异常处理是一种重要的编程实践,但滥用异常处理可能会导致代码变得复杂难以理解。正确地使用异常处理可以帮助您编写更健壮和可维护的代码。

# 2.异常体系

Error错误::程序无法处理的严重错误,我们不作处理,这种错误一般来说与操作者无关,并且开发者与应用程序没有能力去解决这一问题,通常情况下,JVM 会做出终止线程的动作.

Exception异常::异常可以分为运行时异常和编译期异常

  • RuntimeException:即运行时异常,我们必须修正代码

    • 这些异常通常是由于一些逻辑错误产生的这类异常在代码编写的时候不会被编译器所检测出来,是可以不需要被捕获,但是程序员也可以根据需要行捕获抛出,(不受检查异常)这类异常通常是可以被程序员避免的。

    • 常见的 RuntimeException 有:NullpointException(空指针异常),ClassCastException(类型转 换异常),IndexOutOfBoundsException(数组越界异常)等。

  • 非 RuntimeException编译期异常,必须处理,否则程序编译无法通过

  • 这类异常在编译时编译器会提示需要捕获,如果不进行捕获则编译错误。

  • 常见编译异常有:IOException(流传输异常),SQLException(数据库操作异常)等。

image-20230815113157598

# 3.异常输出打印的常用方法

方法方法 说明
public String getMessage() 回关于发生的异常的详细信息。这个消息在 Throwable 类的构造函数中初始化了
public Throwable getCause() 返回一个 Throwable 对象代表异常原因
public String toString() 使用 getMessage()的结果返回类的串级名字
public void printStackTrace() 打印 toString()结果和栈层次到 System.error,即错误输出流

示例:

public class Demo {
    public static void main(String[] args) {
        int a = 520;
        int b = 0;
        int c;
        try {
            System.out.println("这是一个被除数为0的式子");
            c = a / b;
        } catch (ArithmeticException e) {
            System.out.println("除数不能为0");
        }
    }
}

//运行结果
//这是一个被除数为0的式子
//除数不能为0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

我们用上面的例子给出异常方法的测试

// System.out.println(e.getMessage()); 结果如下:
/ by zero
1
2
// System.out.println(e.getCause()); 结果如下:
null
1
2
// System.out.println(e.toString()); 结果如下:
java.lang.ArithmeticException: / by zero
1
2
// e.printStackTrace(); 结果如下:
java.lang.ArithmeticException: / by zero
	at cn.bwh_01_Throwable.Demo.main(Demo.java:10)
1
2
3

# 4.Throw 和 Throws 的区别

Throw

  • 作用在方法内,表示抛出具体异常,由方法体内的语句处理。

  • 具体向外抛出的动作,所以它抛出的是一个异常实体类。若执行了 Throw 一定是抛出了某种异常。

Throws

  • 作用在方法的声明上,表示如果抛出异常,则由该方法的调用者来进行异常处理。

  • 主要的声明这个方法会抛出会抛出某种类型的异常,让它的使用者知道捕获异常的类型。

  • 出现异常是一种可能性,但不一定会发生异常。

# 5.运行时异常

运行时异常 描述
NullPointerException 试图访问空对象的属性或调用空对象的方法。
ArithmeticException 数学运算中,如除以零时抛出的异常。
ArrayIndexOutOfBoundsException 访问数组不存在的索引位置时抛出的异常。
IllegalArgumentException 参数不符合方法预期要求时抛出的异常。
NumberFormatException 尝试将字符串转换为数字类型,但格式错误。
ClassCastException 尝试强制转换不兼容类型的对象时抛出的异常。
ConcurrentModificationException 多线程环境下,迭代同时修改集合抛出的异常。
UnsupportedOperationException 调用对象上不支持操作时抛出的异常。

上述表格中只是列举了一些常见的运行时异常及其描述。在编写代码时,应该遵循良好的编码实践,以避免这些异常的发生,并且在必要时进行适当的异常处理。

# 6.非运行时异常

非运行时异常(Non-runtime Exceptions),也称为编译时异常(Checked Exceptions),是 Java 异常体系的另一类重要异常。与运行时异常不同,编译时异常必须在方法签名中显式声明,并且在代码中要求进行异常处理,否则程序将无法通过编译。

以下是一些常见的非运行时异常及其描述:

非运行时异常 描述
IOException 输入输出操作期间可能发生的异常。
FileNotFoundException 尝试打开不存在文件时抛出的异常。
ParseException 解析字符串为日期、时间等格式时可能出错。
SQLException 访问数据库时可能发生的异常。
ClassNotFoundException 尝试加载不存在的类时抛出的异常。
InterruptedException 线程在等待、睡眠等操作中被中断时抛出的异常。
NoSuchMethodException 调用不存在的方法时抛出的异常。
NoSuchFieldException 访问不存在的字段时抛出的异常。

在编写代码时,处理非运行时异常的方法有两种:使用try-catch块捕获异常或使用throws关键字在方法签名中声明异常,并将异常传递给调用者来处理。编译时异常的处理强制开发人员在代码中采取措施来处理潜在的异常情况,以确保程序的稳定性和可靠性。

# 7.try-with-resources 替代 try-catch-finally

面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是 try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources 语句让我们更容易编写必须要关闭的资源的代码,若采用 try-finally 则几乎做不到这点。—— Effecitve Java

Java 从 JDK1.7 开始引入了 try-with-resources ,在其中定义的变量只要实现了 AutoCloseable 接口,这样在系统可以自动调用它们的 close 方法,从而替代了 finally 中关闭资源的功能。

使用 try-catch-finally 你可能会这样做

try {
    // 假设这里是一组关于 文件 IO 操作的代码
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (s != null) {
        s.close();
    }
}
1
2
3
4
5
6
7
8
9

但现在你可以这样做

try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(new File("test.txt")))) {
    // 假设这里是操作代码
} catch (IOException e) {
    e.printStackTrace();
}
1
2
3
4
5

如果有多个资源需要 close ,只需要在 try 中,通过分号间隔开即可

try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(new File("test.txt")));
     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File("test.txt")))) {
    // 假设这里是操作代码
} catch (IOException e) {
    e.printStackTrace();
}
1
2
3
4
5
6
上次更新: 10/29/2024, 10:27:50 AM