JVM基础篇-01
1.并发与并行?
并发:
同一时间同时发生,内部可能存在串行或者并行.又称共行性,是指处理多个同时性活动的能力。
并行:
同一时间点同时执行,不存在阻塞.指同时发生两个并发事件,具有并发的含义。并发不一定并行,也可以说并发事件之间不一定要同一时刻发生。
区别:
并发和并行是即相似又有区别的两个概念,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机系统中,每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。倘若在计算机系统中有多个处理机,则这些可以并发执行的程序便可被分配到多个处理机上,实现并行执行,即利用每个处理机来处理一个可并发执行的程序,这样,多个程序便可以同时执行。
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.
对象头的另外一部分是类型指针
,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说。查找对象的元数据信息并不一定要经过对象本身,另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据:
是真正存储有效信息的部分.父类信息,子类信息都会记录下来.相同宽度的字段总是分配在一起.子类较窄的变量也可能插入到父类变量的缝隙之中.
对其填充:
不是必须的,主要是占位符的作用,对象的大小必须是 8 字节的整数倍,对象头是 8 字节的整数倍,实例数据需要被对齐填充.
#32位JVM
Object Header: 8 字节
Instance Data: 0 字节 (因为没有任何成员变量)
----------------------
Total: 8 字节
#64位JVM
Object Header: 12 字节 (64位 JVM下对象头通常为12字节)
Padding: 4 字节 (填充字节,用于对齐)
Instance Data: 0 字节 (因为没有任何成员变量)
----------------------
Total: 16 字节
6.对象的访问定位的方式?
Student student = new Student();
具体是如何操作 student 对象呢?有以下两种方式
- 使用句柄
- 直接指针
如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息.
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 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位
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops
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";
}
}
对象头 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;
}
}
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;
}
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);
}
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);
}
StringBuffer 的 append 的实现其实是 System 类中的 arraycopy 方法实现的,这里的浅复制就是指复制引用值,与其相对应的深复制就是复制对象的内容和值;
14.jvm 中几种常见的 JIT 优化?
在 JVM(Java 虚拟机)中,有几种常见的即时编译(Just-In-Time,JIT)优化技术,它们用于将 Java 字节码转换成本地机器码,以提高 Java 应用程序的性能。以下是几种常见的 JIT 优化:
内联(Inlining)优化:
内联是指将方法调用处直接替换为被调用方法的实际代码,避免了方法调用的开销。JIT 编译器会分析程序的执行热点,对其中的短小方法进行内联优化,减少了方法调用的开销,提高了代码执行效率。逃逸分析(Escape Analysis)优化:
逃逸分析是 JIT 编译器对对象的动态作用域进行分析,判断一个对象是否逃逸出方法的作用域。如果对象不逃逸,可以将其分配在栈上而不是堆上,避免了垃圾回收的开销,提高了程序的性能。标量替换(Scalar Replacement)优化:
标量替换是指 JIT 编译器将一个对象拆解成其各个成员变量,并将这些成员变量分别使用标量(如基本数据类型)进行优化。这样可以避免创建和销毁对象的开销,减少了堆上的内存分配和垃圾回收压力。循环展开(Loop Unrolling)优化:
循环展开是指 JIT 编译器对循环体进行优化,将循环体中的代码重复展开多次,减少循环的迭代次数。这样可以减少循环控制的开销和分支预测错误的影响,提高了循环的执行效率。方法内联缓存(Monomorphic/Megamorphic Inline Cache)优化:
方法内联缓存是 JIT 编译器为了优化虚方法调用而采取的一种策略。它会为不同的目标类型建立缓存,并根据目标类型来直接调用对应的方法,避免了虚方法查找的开销。常量折叠(Constant Folding)优化:
常量折叠是指 JIT 编译器在编译时将常量表达式计算得到结果,并用结果直接替换表达式。这样可以避免在运行时重复计算相同的常量表达式,提高了程序的执行效率。
15.逃逸分析
逃逸分析是一种在即时编译(JIT)优化过程中用于分析对象的动态作用域的技术。它的目标是确定一个对象是否"逃逸"出了方法的作用域
,即是否被方法外的其他部分所引用。如果对象没有逃逸,那么 JIT 编译器可以将其优化为栈上分配而不是堆上分配,从而提高程序的性能。
逃逸分析的实现过程通常包括以下几个步骤:
标记阶段::
JIT 编译器通过静态分析,标记方法中哪些对象是被分配在堆上的,并且记录下这些对象的创建点。逃逸分析阶段:
JIT 编译器在动态执行过程中进行逃逸分析,观察对象的引用情况,判断对象是否逃逸出方法的作用域。如果一个对象的引用从方法内传递到方法外,那么它就被认为是逃逸的。
逃逸分析主要有以下两种类型:
全局逃逸:
对象的引用逃逸到了方法外部,可能被其他线程访问,或者返回给了调用者。栈上分配:
对象的引用没有逃逸,仅在方法内部可见,可以将其分配在栈上,而不需要在堆上分配。栈上分配的对象在方法返回时自动释放,不需要进行垃圾回收。
逃逸分析带来的好处:
减少堆内存分配:
通过栈上分配非逃逸对象,可以减少垃圾回收的压力,降低堆内存分配的开销,提高程序的执行效率。锁消除:
对于逃逸对象,由于可能被其他线程访问,需要使用锁进行同步。但对于栈上分配的对象,由于其仅在方法内部可见,可以进行更加精确的锁消除,避免不必要的同步开销。标量替换:
逃逸分析可以帮助 JIT 编译器进行标量替换优化,将对象拆解成标量(如基本数据类型)进行优化,减少了对象访问的开销。
需要注意的是,逃逸分析并非总是带来性能提升,它会增加编译器的复杂度和开销,而且逃逸分析的准确性也受到程序的复杂性和运行环境的影响。因此,JIT 编译器通常会在逃逸分析和栈上分配之间进行权衡,根据具体的情况来决定是否进行逃逸分析和优化。
16.栈上分配
栈上分配(Stack Allocation)是一种内存分配的技术,通常用于编程语言中的局部变量或短暂对象。它的基本思想是将对象分配在调用栈(函数调用的栈帧)中的栈上,而不是在堆上分配内存。
以下是栈上分配的关键特点和优点:
-
生命周期短暂:栈上分配适用于那些生命周期非常短暂的对象,这些对象在函数执行完毕后就不再需要。由于栈上分配的对象生命周期与函数的执行周期一致,因此无需垃圾回收器来回收它们。
-
性能优势:相对于在堆上分配对象,栈上分配具有更快的分配和释放速度。这是因为在栈上分配只需要简单地调整栈指针,而不需要复杂的内存管理操作。
-
无需垃圾回收:栈上分配的对象在函数执行完毕后会自动被释放,不需要垃圾回收器来进行清理。这可以减轻垃圾回收的负担,降低了内存管理的复杂性。
-
局部性原理:栈上分配有助于提高内存访问的局部性原理,因为对象在栈上分配时,它们的数据通常存储在相邻的内存位置,这有助于缓存的有效利用。
栈上分配是一种优化技术,适用于特定情况下,尤其是对于生命周期短暂的对象。在编程语言和编译器中,栈上分配通常由编译器进行自动优化,程序员无需手动管理。在一些编程语言中,如 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执行一些操作
}
如果尝试在匿名内部类中访问非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的值不受方法内部修改的影响
引用数据类型(传值): 当将引用数据类型(如对象或数组)作为参数传递给方法时,传递的是该引用的值的副本,也就是对象在堆内存中的地址。因此,方法内对参数所指向的对象进行修改,会影响原始对象的内容。但是,如果在方法内部重新分配了一个新的对象,那么原始对象的引用不会受到影响。
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,原始数组引用未受影响
虽然在传递引用数据类型时,方法内部的修改会反映在原始对象上,但仍然可以认为这是按值传递,因为传递的是引用的值(地址)的副本。
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
关键字的原因:
-
Java 内存模型(Java Memory Model,JMM):JVM 定义了自己的内存模型,即 Java 内存模型(JMM),它规定了多线程程序中共享变量的访问规则。JMM 确保了在多线程环境下,共享变量的操作是可见的和有序的。
volatile
关键字就是用来保证这种可见性和有序性的一种方式。 -
缓存一致性与内存可见性的不同层次:缓存一致性协议是硬件层面的协议,它确保了不同 CPU 核心之间的缓存一致性,但它不提供高级语言层面的内存可见性和同步。
volatile
关键字用于确保在 Java 程序中,一个线程对共享变量的修改对其他线程是可见的,而不仅仅是在 CPU 缓存之间。 -
禁止指令重排序:
volatile
关键字还可以防止编译器和处理器对代码进行一些优化,以确保指令不会被重排序。这也有助于保证多线程环境下的操作顺序是按照程序员的意图执行的。 -
不同硬件平台的一致性:Java 是跨平台的语言,运行在不同的硬件架构上。不同的硬件架构可能对缓存一致性有不同的实现方式,但
volatile
关键字提供了一种在所有平台上都一致的方式来确保内存可见性。
虽然 CPU 的缓存一致性协议有助于解决硬件层面的一致性问题,但在高级编程语言中,特别是 Java 这样的跨平台语言中,仍然需要volatile
关键字来确保在多线程环境下的内存可见性和操作有序性。这样可以使程序员更容易编写正确的多线程代码,而不需要深入了解底层硬件的细节。
- 点赞
- 收藏
- 关注作者
评论(0)