几个挺有意思的Java疑问

记录下 Java 学习过程中产生的一些疑问。


日常对 class 的命令为什么几乎没出现过 $ ?

要知道 class 的命名是允许使用 $ 的,但是无论是网上各种博客的代码片段,框架源码和文档几乎没出现过 class 命名中带 $ 的情况。是单纯因为不好看吗?这个问题直到我看到内部类的编译的 .class 文件之后才被解开。

在一个类(Outer)内部定义的类(Inner),经过编译后产生的 .class 文件是带 $ 符号的(Outer$Inner.class),所以为了避免和经过编译的后产生的带 $ 符号的 .class 文件混淆,我们自己定义的类最好不带 $ 符号。


为什么在 Outer Class 的 method 里面实现的 Inner Class 里只能访问经过 final 修饰的 Outer Class 的 method 变量 ?

问题很绕,写段简单代码说明,为什么 innerMethod 访问的 i 必须是 final 修饰:

1
2
3
4
5
6
7
8
9
10
class Outer {
public void outerMethod() {
final int i = 10;
class Inner {
public void innerMethod() {
System.out.println(i);
}
}
}
}

因为 Inner Class 是 Class ,使用 new 操作符创建的对象存活在堆区。对象一旦被创建就会一直存活,直到被垃圾回收;而 Inner Class 所在的 outerMethod 是 method,里面的变量 i 是存活在栈区,随着 method 执行完会被马上回收;所以 Inner Class 的存活时间会被 i 要长,所以如果要在 Inner Class 安全使用 i 的话只能通过复制,而只有经过 final 修饰的变量(不可变)的复制使用才有意义。


不重写 toString ,直接打印对象得到的是包含对象的内存地址的字符串?

首先现代操作系统早已支持虚拟内存,所以至少不可能是物理地址。那么是虚拟地址吗?也不是,打印出来的只是对象的十六进制 hashCode,通过重写 hashCode 方法可以验证。

不同对象 hashCode 值可能相同(哈希碰撞),但是地址肯定不会相同。换句话说,通过打印对象观察后半部分的 hashCode 是否相同来判断是否为同一对象的做法并不严谨。


有了 == ,为什么还需要 equals ?

两者都是根类 Object 的方法,== 默认是比较变量栈中内容,所以导致了对于基本变量类型而言,== 是比较值(栈中直接是存储值);而对于 new 出来的引用对象类型而言,比较的是对象的堆引用地址(栈中是存储对象的堆引用地址),即比较两者是否为同一对象。

为什么还需要 equals ?有时候我们想根据对象中的某些字段(堆中的内容)决定两者是否相同,这时候就需要重写 equals 实现。继承自根类 Object 不重写 equals 默认就是使用 == 。


POJO & JavaBean

POJO: 简单 Java 类,没有继承任何类(非 Object 根类)和实现任何接口,没有注解,没有其他方法。如此简单的类,通常用来装载传递数据使用,不封装业务。

JavaBean: 规定所有的属性为 private,提供对应符合命名规范的 getter & setter ,有一个无参构造函数,同时类必须是可序列化的。可以有别的方法,允许封装部分业务。


为什么无法构建基本类型的泛型对象?

因为 Java 的泛型实现方法是擦拭法,即 JVM 并不知道泛型,泛型只是提供给编译器使用。我们所定义的 T 当被编译成 .class 文件后被 JVM 执行都会被转换成 Object,基本数据类型无法向上转型为 Object ,只有其包装类可以。

泛型的作用只是给编译器帮我们做安全的强制转型。


为什么有些异常不处理编译器会报错,有些不会?

首先 Java 异常分为两大类:Error & Exception,它们都继承自Throwable

Error 代表严重错误,通常只能避免,无法处理,如 OutOfMemoryErrorStackOverflowError

Exception 代表运行错误,应当被捕获并处理,如 FileNotFoundExceptionSocketException

同时 Exception 又分为两大类:

  • RuntimeException 类及其子类。
  • 继承自 Exception 的非 RuntimeException 类及其子类。

其中 Error 及其子类 & RuntimeException 及其子类可以不被捕获处理,其他(Exception 类和非 RuntimeException 类及其子类)必须要被捕获处理。


不写权限修饰符代表的是 public 吗?

不是,不写权限修饰符代表的是 default 权限,不同情况下四种权限修饰符的访问权限如下:

public protected default private
同一个类 Y Y Y Y
同一个包 Y Y Y N
不同包子类 Y Y N N
不同包非子类 Y N N N

什么情况下使用 .setAccessible(true) 会失败?

我们知道当需要读取/修改一个私有变量的时候,可以先通过反射 cls.getDeclaredField("xxx") 获取到对应的 Field ,调用 field.setAccessible(true) 之后,再调用对应的 get 方法和 set 方法。(私有构造方法,成员方法同理)

但是调用 .setAccessible(true) 是会失败的,例如我们想要通过反射修改 JVM 核心库的类(java 和 javax 开头的 package 里的类)的时候,会有相应的 SecurityManager 进行规则检查,从而拒绝 .setAccessible(true) 执行,以保证 JVM 的安全。


为什么定义注解是 public @interface Annotation 的写法?

刚学注解觉得这样的写法很奇怪,注解到底还是不是接口。

注解是接口,自定义注解的写法只是快速定义注解接口的一种语法糖。定义一个最简单的注解如下:

1
2
// MyAnnoatioin.java
public @interface MyAnnoatioin {}

然后使用 javac 命令对其进行编译,得到 MyAnnoatioin.class 文件,再使用 javap 命令对得到的 class 文件进行反编译,得到没有使用语法糖的注解定义:

1
2
3
4
5
6
7
8
9
➜ javac MyAnnoatioin.java
➜ ll
total 16
-rw-r--r-- 1 Gra staff 156B 3 3 20:27 MyAnnoatioin.class
-rw-r--r-- 1 Gra staff 57B 3 3 20:13 MyAnnoatioin.java
➜ javap MyAnnoatioin.class
Compiled from "MyAnnoatioin.java"
public interface com.peterxx.MyAnnoatioin extends java.lang.annotation.Annotation {
}

可见,注解也是接口,只是通过 @interface 简化了接口继承自 java.lang.annotation.Annotation 的操作。


Java 注解是如何实现的?

因为注解也是接口,所以注解的“属性”也就是接口的方法。

当我们使用 MyAnnoatioin annotation = cls.getAnnotation(MyAnnoatioin.class); 获取某个注解的时候其实是 JVM 先在内存里创建了一个接口(该注解)的实现类,并使用该注解设置的属性值作为实现方法的返回值,最后将该实现类的实例返回。所以我们能够直接通过 annotation 对象调用方法获取到设置值。

示例代码:

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
// MyAnnoatioin.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnoatioin {
String name() default "Peter";

int age() default 0;
}

...

// Main.java
@MyAnnoatioin(name = "Peter Gra", age = 19)
public class Main {
public static void main(String[] args) {
if (Main.class.isAnnotationPresent(MyAnnoatioin.class)) {
/*
* 获取某个注解相当于返回该注解的实现类对象,该实现类使用注解设置值作为方法的返回值
* 获取到 annotation 对象等效于 MyAnnoatioinImpl 类的实例
*/
MyAnnoatioin annotation = Main.class.getAnnotation(MyAnnoatioin.class);
System.out.println(annotation.name());
System.out.println(annotation.age());
}
}
}

...

// 调用 getAnnotation 获取注解时,JVM 会生成如下的实现类
class MyAnnoatioinImpl implements MyAnnoatioin {
@Override
public String name() {
return "Peter Gra";
}

@Override
public int age() {
return 21;
}

@Override
public Class<? extends Annotation> annotationType() {
return MyAnnoatioin.class;
}
}


子类能否覆写父类的静态方法?

子类可以创建相同签名的静态方法,但是不能称为覆写父类静态方法。称为隐藏(Hide)。

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
public class Main {
public static void main(String[] args) {
Sup sub = new Sub();
sub.normalMethod();
sub.staticMethod();
}
}

class Sup {
public static void staticMethod() {
System.out.println("Sup.staticMethod");
}
public void normalMethod() {
System.out.println("Sup.normalMethod");
}
}

class Sub extends Sup {
public static void staticMethod() {
System.out.println("Sub.staticMethod");
}
@Override
public void normalMethod() {
System.out.println("Sub.normalMethod");
}
}

这时候输出为:

1
2
>>> Sub.normalMethod
>>> Sup.staticMethod

产生这样的输出结果,是因为一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type)。

表面类型是声明时的类型,实际类型是对象产生时的类型。

对于非静态方法,它是根据对象的实际类型来执行的。而对于静态方法来说,如果是通过对象调用静态方法,JVM 则会通过对象的表面类型查找到对应的类,再使用对应的类名进行调用。

也就是说执行 sub.staticMethod(); 时会因为 sub 是使用 Sup 类进行声明,变成执行 Sup.staticMethod();

所以,在调用静态方法的时候最好使用类名调用,而不是使用对象调用。


变量的“声明”和“使用”顺序能颠倒吗?

实例变量可以,但是静态变量不行。为了避免不必要的混淆,最好统一遵循“变量先声明再使用”的原则。

因为静态变量是在类加载的时候分配空间,之后随着静态代码块或者静态变量声明赋值的先后顺序执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main 

// public static int i = 100;
static {
i = 1000;
}
// public static int i = 100;

public static void main(String[] args) {
// 静态变量的声明赋值如果出现在静态代码块赋值之前 打印1000
// 静态变量的声明赋值如果出现在静态代码块赋值之后 打印100
System.out.println(i);
}
}

而实例变量是在调用构造函数前被初始化。而且当子类实例化时,首先会先初始化父类。


使用 synchronized 修饰方法和使用 synchronized 来构建同步代码块有何区别?

使用 synchronized 修饰方法和使用 synchronized 来构建同步代码块底层原理是一样。

只是使用 synchronized 修饰方法默认会使用 this 作为锁对象。


synchronized 方法 & 同步代码块如何选择?

建议使用同步代码块。

出于并发性能的考虑,被 synchronized 包裹的代码越少越好。使用 synchronized 方法默认会使用 synchronized 包裹整个方法,而使用同步代码块则能自己控制同步作用范围。

而且 synchronized 方法只能使用 this 作为锁对象,方法调用者本身的作用域通常较大,不能保证不会被其他线程作为锁使用;而同步代码块能够自己决定锁对象,通常可以使用范围特定的专门对象作为锁,更加安全灵活。


为什么 Java 数组对象的 getClass() 方法不显示为 class Array ?

Java 里如果是基本类型的数组会显示 class [ + 基本类型首字母大写。如 int[] 对象的 getClass() 会显示 class [I ;double[] 显示 class [D ;char[] 显示 class [C 。

如果是引用类型,则会显示 class [ + 引用类型的全类名。Integer[] 显示为 class [Ljava.lang.Integer ;String[] 显示为 class [Ljava.lang.String ;

而非数据类型,则会显示 class + 数据类型类名。如 ArrayList 对象的 getClass() 会显示为class java.util.ArrayList。为什么 Java 数组对象的 getClass() 不直接显示 class Array 呢?

答案是因为Array是属于 java.lang.reflect 包,它是通过反射访问数组元素的工具类,而不是具体的一个数组类。真正 class [ 类在编译期间生成的。


基本类型数组转 List 的问题

先来看一段代码:

1
2
3
4
5
public static void main(String[] args) {
int[] ints = {1, 2, 3, 4, 5};
List list = Arrays.asList(ints);
System.out.println(list.size());
}

请思考以上代码输出是多少?答案是 1 ,而不是 5 。

明明是一个有 5 个成员的 int 类型数组,通过 asList 转成列表之后则变成了 1 个?

这是因为基本类型不能泛型化,所以 int 类型的数组转成 List 时候,int 数组的成员 int 不能作为 List 的成员,但是 int[] 可以被泛型化,所以最终整体的 int 数组作为成员转成了 List 对象。也就是说转成的 List 对象其实是 List<int[]> 类型的,所以打印结果为 1 。

1
2
3
4
5
public static void main(String[] args) {
int[] ints = {1, 2, 3, 4, 5};
List<int[]> list = Arrays.asList(ints);
System.out.println(list.get(0).equals(ints)); // 输出 true
}


asList 方法产生的 List 对象不可修改

除了上面说到的基本类型数组通过 asList 方法转 List 需要注意基本类型不能泛型化的问题以外,asList 方法还有一个需要注意的地方,先看以下代码:

1
2
3
4
5
public static void main(String[] args) {
Integer[] ints = {1, 2, 3, 4, 5};
List<Integer> list = Arrays.asList(ints);
list.add(6);
}

这段代码会抛 java.lang.UnsupportedOperationException 异常,不支持 add 操作。

通过查看 Arrays 的源码发现,通过 asList 方法返回的不是我们平时使用的 java.util.ArrayList 对象,而是一个 Arrays 内部自己实现的 ArrayList 类,该对象也是继承自 AbstractList ,但是自身没有覆写 AbstractList 的 add(int index, E element) 方法,所以当调用 asList 返回的 List 对象的 add 方法时,会由 AbstractList 抛出 java.lang.UnsupportedOperationException 异常。即 asList 方法产生的 List 对象不可修改

注意,这里说的不能修改除了不能添加元素还包括不能移除元素。因为 Arrays 内部的 ArrayList 类不仅仅没有实现 add 方法,也没有实现 remove 方法。

也由此引出,如果想在定义 List 的同时完成初始化工作,使用 Arrays.asList 不是一个好点习惯,因为返回的是一个不可操作的 ArrayList 对象,可以使用匿名类 + 构造代码块的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) {
// 错误
// List<Integer> readOnlyList = Arrays.asList(1,2,3);
// readOnlyList.add(4);
// System.out.println(readOnlyList);

// 正确
List<Integer> normalList = new ArrayList<Integer>() {
{
add(1);
add(2);
add(3);
}
};
normalList.add(4);
System.out.println(normalList);
}


实现排序,是使用 Comparable 接口 还是 Comparator 接口

Comparable 接口的类表明自身是可比较的,有了比较才能进行排序;

Comparator 接口是一个工具类接口,它的名字(比较器)也已经表明了它的作用:用作比较,它与原有类的逻辑没有关系,只是实现两个类的比较逻辑。

从这方面来说,一个类可以有很多的比较器,只要有业务需求就可以产生比较器,有比较器就可以产生 N 多种排序,而 Comparable 接口的排序只能说是实现类的默认排序算法,一个类稳定、成熟后其 compareTo 方法基本不会改变,也就是说一个类只能有一个固定的、由 compareTo 方法提供的默认排序算法。

Comparable 接口可以作为实现类的默认排序法,Comparator 接口则是一个类的扩展排序工具。


为什么在接口定义中,推荐使用枚举作为入参,但是不推荐枚举作为返回值

这是为了更好实现接口兼容所定的原则。

这在二方库和三方库里接口设计中尤为重要,遵循该原则设计的接口能够保证当“服务使用方的本地枚举”和“服务提供方的最新枚举”版本不一致时,服务还能够正常工作。

我们可以列举一个场景来说明问题,假如有以下接口和枚举:

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
// 服务方提供
enum OperationActionEnum {
LOGIN,
LOGOUT,
REFRESH;
}

enum OperationResultCodeEnum {
SUCCESS,
FAILURE;
}

interface ServiceApi {
OperationResultCodeEnum doSomething(OperationActionEnum action);
}

// 使用方使用
public static void main(String[] args) {
...
if (OperationResultCodeEnum.success == serviceApi.doSomething(action)) {
// 成功逻辑
} else {
// 失败逻辑
}
...
}

  • 为什么推荐使用枚举做为入参?

    后续添加枚举类型( 假如添加了一个 OperationActionEnum 枚举 FREEZE ),并不影响接口兼容。

    “服务使用方”调用服务的时候只传其本地有的枚举类型,新增的枚举本地根本没有,也就不会传递。换句话说,这时候新增什么内容不影响原有逻辑。

    这时候入参使用枚举甚至还起到了参数校检的作用,只有支持的枚举才能够被传递。服务的调用也是以“服务使用方”的本地枚举为准。


  • 为什么不推荐使用枚举作为返回值?

    后续添加枚举类型( 假如添加了一个 OperationResultCodeEnum 枚举 SUCCESS_AND_USING_CACHE ,代表成功并且使用到了缓存),这时候如果出现“服务使用方的本地枚举”和“服务提供方的最新枚举”版本不一致,可能会引发灾难。

    想象一下假如“服务使用方”原本只处理了 OperationResultCodeEnum 的 SUCCESS 枚举,其他情况都走失败逻辑,这时候“服务提供方”新增一个细化成功原因的枚举,直接导致了“服务使用方”原来成功的用户走到了失败的逻辑,这显然没有做到接口兼容。

    这时候的服务调用是以“服务提供方”的最新枚举为准。

    这是“服务使用方”处理原有枚举类型不严谨的错吗?不完全是。更多的责任落在“服务提供方”设计接口时的考虑不全。

在进行接口设计时,推荐使用枚举作为入参,但是不推荐枚举作为返回值

注意:这里指的枚举除了枚举本身还包括使用了枚举的 POJO 。


枚举还是常量类

能用枚举就用枚举,不能用枚举才考虑使用常量类或接口常量。

使用枚举有如下优点:

  • 更加纯粹。有时候我们定义一个常量,通常只强调其枚举项是什么,而不关心甚至不需要指定其值;但是用常量类或者接口常量通常需要和 int 类型进行绑定。
  • 使用 switch 的时候不需要进行越界检查。如果使用常量类或者接口常量的方式,因为通常会跟 int 或者 String 进行绑定,如果和 int 进行绑定之后,免不了先对枚举使用到的 int 范围进行检查;但是使用枚举类则不需要。
  • 枚举类提供了一系列的内置方法。例如使用枚举类可以很方便的列出所有枚举项;使用常量类或者接口常量则需要自己利用反射实现。

List<T>、List<?> 和 List<Object> 的区别

List<T>、List<?> 和 List<Object> 这三者都可以容纳所有的对象,但使用的顺序应该是 List<T>、List<?> 和 List<Object> 。

List<T> 表示的是 List 集合中的元素都为 T 类型,具体类型在运行期决定。使用 List<T> 可以进行读写操作;

List<?> 表示的是任意类型,与 List<T> 类似。但使用 List<?> 只能进行读操作;

List<Object> 表示 List 集合中的所有元素为 Object 类型,由于多态,所有对象都可以装载进 List<Object> 。List<Object> 可以进行读写,但是执行写入操作时需要向上转型(Up cast),在读取数据后需要向下转型(Downcast)。


Java 的动态代理实现方式

在 Java 8 中,动态代理的实现方式主要分为两种:

  • JDK 动态代理:利用 Java 的反射机制实现的动态代理,要求业务类必须实现了接口才能使用,通过动态生成一个与业务类实现了相同接口的代理类来实现
  • cglib 动态代理:利用 ASM 机制实现,要求业务类不能使用 final 修饰,通过生层业务类的子类作为代理类

反射时 Accessible 设为 true 的真实含义

网上不少资料说反射时调用是 setAccessible(true) 是为了能够访问私有类型的方法和字段。其实不完全正确。

因为 public 的方法也会存在 Accessible 为 false 的情况,例如:

1
2
3
4
5
6
7
8
9
10
class AccessibleTest {
public final void method() {
System.out.println("AccessibleTest.method");
}

public static void main(String[] args) throws NoSuchMethodException {
Method method = AccessibleTest.class.getMethod("method");
System.out.println(method.isAccessible()); // 打印为 flase
}
}

所以设置 Accessible 为 true 并不是单纯的因为访问权限。那么设置 Accessible 到底改变什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void setAccessible(boolean flag) throws SecurityException {
SecurityManager sm = System.getSecurityManager();
if (sm != null) sm.checkPermission(ACCESS_PERMISSION);
setAccessible0(this, flag);
}

private static void setAccessible0(AccessibleObject obj, boolean flag)
throws SecurityException
{
if (obj instanceof Constructor && flag == true) {
Constructor<?> c = (Constructor<?>)obj;
if (c.getDeclaringClass() == Class.class) {
throw new SecurityException("Cannot make a java.lang.Class" +
" constructor accessible");
}
}
// 设置 Accessible 为 true 本质是改变 override 属性
obj.override = flag;
}

我们知道 Accessible 为 false 的 Method 在不设置 setAccessible(true) 是无法被调用的(通过 invoke 方法),这意味着在 invoke 源码中必然存在对 Accessible 的检查逻辑。

深入源码可以发现,设置 Accessible 为 true 是为了实现快速调用,忽略安全检查,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
// 如果 override 为 false 要先进行各项检查
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}

// 调用方法
MethodAccessor ma = methodAccessor;
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

设置 Accessible 为 true 并不是单纯的因为访问权限。出于对性能的考虑,使用反射时务必要设置 Accessible 为 true。


Java 的几种引用类型有何区别

  1. 普通引用:就是日常使用得最多的引用。

1
Object o = new Object();

  1. 弱引用:对于弱引用,最经典的场景是用作 ThreadLocal 内部类 ThreadLocalMap 中的 Entry 。使用弱引用的目的是保证 ThreadLocal 置空的时候,能够被顺利回收(因为 ThreadLocal 本身作为了 Map 的 key 使用)如果一个对象只有弱引用指向,触发 GC 时必然会被回收。

1
WeakReference<Object> stringWeakReference = new WeakReference<Object>(new Object());

  1. 软引用:如果一个对象只有软引用指向,触发 GC 时有可能会被回收(当内存充足的情况下,对象可能不会被回收,在抛出 OutOfMemoryError 之前,软引用必然会被清理)。

1
SoftReference<Object> objectSoftReference = new SoftReference<Object>(new Object());

  1. 虚引用:基本上用不上,通常用作管理“堆外内存”。一旦虚引用对象被回收,将会把消息通知到某个队列。

1
2
ReferenceQueue<Object> queue = new ReferenceQueue<Object>();
PhantomReference<Object> objectPhantomReference = new PhantomReference<Object>(new Object(), queue);

以上几种引用都是类 java.lang.ref.Reference 的子类,都可以调用 get() 方法获取到原有对象(除了虚引用,会始终返回 null)。能拿到原有对象,意味着能够重新指向强引用,也就是可以人为的改变了对象的可达性状态。


new Foo().action() 有何问题

这种调用 action() 的方式称为链式编程。但在低版本的 JDK 中可能会引发对象意外回收。

因为 GC 依赖的是可达性分析,而 Foo 对象并不可达,所以 JVM 在 GC 的时候,会把调用 action() 的 Foo 对象回收掉,但是我们又实实在在的“正在”使用该对象。

为了解决这类对象意外回收问题,我们可以这样修改 action() 方法:

1
2
3
4
5
6
7
public void action() {
try {
// do someting ...
} finally {
Reference.reachabilityFence(this);
}
}

这段代码的意思是在 action() 方法返回之前,Foo 对象会保持强引用状态。


volatile 的作用和原理

volatile 的作用是保证线程可见性和禁止指令重排序

  1. 什么叫保证线程可见性?

在 Java 线程模型中,每个线程有一个独立的工作内存空间(里面的数据从主存中复制而来),在线程执行时,使用的是工作内存中的变量值。假如两个线程同时读取了主存中的某个数据到工作内存,当其中一个线程在工作内存中对变量进行修改,另外一个线程是没法及时知道变量被修改了。保证线程可见性的意思是希望变量的修改能够及时被其他线程知道

  1. volatile 如何保证线程可见性?

通过 store 指令和 load 指令实现。如果一个变量被 volatile 修饰了,那么 JVM 会在读这个变量的指令之前插入一条 load 指令,表示重新从主存中读取该变量到工作内存;在写这个变量的指令之后插入一条 store 指令,表示将工作内存中的值更新到主存。

  1. 什么是禁止指令重排序?

CPU 为了提高执行效率,对于大多数指令其实都是允许乱序执行的。允许乱序执行的意思不是指 CPU 会去调整先执行哪条指令再执行哪条指令,指令仍然是按照顺序开始执行,只不过允许乱序是指一条执行的开始不一定要等待上一条指令的结束。也就是 CPU 总是顺序取指令,最终可能乱序执行完成(即只有编译器的乱序才是真正的乱序,CPU 的乱序严格意义上不算乱序)。

假如有 a = x;b = y; 前后两行指令,在 a = x; 执行完之前(可能需要等待 IO),会执行 b = y; 。导致的结果可能是虽然 b = y;a = x; 后面,但是比 a = x; 先执行完。禁止指令重排序的意思是必须在 a = x; 执行完了之后再开始执行 b = y;

  1. volatile 如何实现禁止指令重排序?

通过添加内存屏障来实现。在读取 volatile 变量的指令前添加 LoadLoadBarrier,在后添加 LoadStoreBarrier;在写 volatile 变量的指令前添加 StoreStoreBarrier,在后添加 StoreLoadBarrier。

  1. volatile 修饰非基本数据类型,能够起到保证线程可见性的作用吗?

首先当 volatile 修饰基本数据类型的变量时,因为基本类型变量的值直接就在栈上,当修改了变量的值时,栈上对应的内容发生改变,volatile 的内存屏障可以保证线程可见性;

但当 volatile 修饰的是一个非基本数据类型的变量(如一个 ArrayList 对象)时,这时候栈上的变量存储的是对象句柄,而不是 ArrayList 对象里面的内容,当修改 ArrayList 对象里的内容,而不是修改栈上变量的指向时,由于栈上内容没有发生变化(仍然是指向那个 ArrayList 对象句柄),所以 volatile 的不能保证 ArrayList 对象里内容的修改的线程可见性。


Java 是编译执行还是解释执行

既有编译执行也有解释执行。在 Hotspot 实现中,默认是混合执行。混合执行的意思是刚开始代码是解释执行,而对于一些反复被执行的热点代码会采取编译执行的方式。

更为具体的解释为:代码刚开始是被解释执行,同时每个方法有一个调用计算器,当调用次数达到阈值,JVM 会认为这属于热点代码,会将该方法编译成 C++ 本地代码。随后执行将不再采用解释执行方式,而是直接执行编译好的 C++ 本地代码,由此提高执行效率。

JVM 也提供了指定执行模式和热点方法调用阈值的参数(JDK 1.8):

-Xmixed :混合执行模式。为默认值。

-Xint :纯解释模式。启动很快,执行很慢。

-Xcomp :纯编译模式。启动很慢(当类数目较多的时候),执行很快。

-XX:CompileThreshold :指定方法调用次数超过多少被定义为热点方法,默认为 10000。


Java 对象的创建过程

  1. 加载类

    1. class loading。将 class 文件加载进内存,并创建一个 Class 对象指向它。
    2. class linking ( verification, preparation, resolution )。在 preparation 阶段为静态变量分配内存,并赋默认值。
    3. class initializing。执行静态内容(包括静态变量赋起始值和执行静态代码块,按编写顺序执行)。
  2. 在堆内存里申请空间。包括为实例变量分配内存,并赋默认值;此时对象处于“半初始化”状态。

  3. 执行非静态内容(包括实例变量赋起始值和执行构造代码块,按编写顺序执行)。

  4. 执行构造方法。

  5. 将栈变量指向堆内存创建好的对象。

注意:不考虑 CPU 乱序执行的情况下的对象创建过程。实际情况中,第 5 步可能会先于第 3 步或者第 4 步完成,即栈变量指向一个“半初始化”状态的堆对象。


内存溢出和内存泄露的区别

内存泄露(Memory Leak):是指某一块内存分配给某个对象后,该对象已经不被使用,但是这块内存一直被占着,没法释放。

内存溢出(Out Of Memory ):通常指内存不足。


sleep() & awit() & yield()

sleep() 是 Thread 类的静态方法。目的是将当前线程的 CPU 时间让出,但不会让出锁,所以在能在任意地方执行。调用了 sleep() 方法之后,线程会从 RUNNABLE 状态变为 TIME_WAITING 状态,时间达到之后,自动切换回 RUNNABLE 状态。也就是等待时间结束之后,一旦得到 CPU 时间片将会继续执行。

awit() 是 Object 的实例方法。目的是让出锁,并将线程放入等待队列(从 Runnable 变为 Waiting 状态或者 TIMED_WAITING 状态),所以只能在同步方法或者同步代码块中执行(已经获取到了锁)。当调用 awit() 之后只有调用了 notify() 或者 notifyAll() 才能重新回到 Runnable 状态,才有可能被执行。

yield() 是 Thread 类的静态方法。和 sleep() 类似,会将当前 CPU 时间让出,但和 sleep() 不同的是,sleep() 让出的 CPU 时间会被其他线程抢夺,而 yield() 让出的 CPU 时间只会被优先级与当前线程相同,或者优先级比当前线程更高的处于就绪状态的线程所获得。sleep() 方法会使线程从 RUNNABLE 状态变为 WAITING 状态,而 yield() 只会将线程从 RUNNING 状态变为 READY 状态(仍然是 RUNNABLE 状态)。


如何实现一个 Java Immutable 类

任何手段都躲不过反射,这里说的如何实现一个 Java Immutable 类是指如何确保在常规情况下是 Immutable 的。

  • 将 class 自身声明为 final。确保常规情况下无法通过继承该类来实现拓展
  • 将所有成员变量定义为 private 和 final,并且不提供 setter 方法,确保无法通过正常手段访问成员变量
  • 在构造方法中,对入参进行深拷贝之后再赋值给成员变量。确保上游在创建了类实例之后,通过修改入参对象来实现对 Immutable 类实现的成员变量的修改
  • 在需要提供 getter 方法时,每次先对成员变量进行深拷贝再返回出去,确保上游不能直接访问到成员变量

Java GC 的时候的 STW 是如何让用户线程停下来的

JVM 通过设定 Safe Point 作为用户线程是否暂停的检查点。Safe Point 是指 Java 代码中的一些特殊位置(如方法返回处、有界循环结束处、无界循环回跳处、异常抛出处…),当用户线程到达 Safe Point 的时候会检查 JVM 的标识位,如果标识位为真则暂停执行。

当所有的用户线程都到达了 Safe Point 之后,JVM 再将其统一挂起。所以 Java GC 的过程中有一个等到所有线程到达 Safe Point 的过程。

Safe Point 机制只能保证正在执行的程序(分配了 CPU 时间的程序)可安全停下来,但对于未分配 CPU 时间的线程如处于 Sleep 状态或 Blocked 状态的,由于其无法响应 JVM 的中断请求,因而无法执行到 Safe Point 再挂起,所以 Safe Point 机制对其不起作用

因此还有 Safe Region,安全区域机制。Safe Region 是指在一段代码片段中,引用关系不会发生变化,在区域中的任意地方开始 GC 都是安全的。

当线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,在这段时间里 JVM 要发起 GC 时,则忽略标识自己为 Safe Region 状态的线程;在线程要离开 Safe Region 时,要检查系统是否已经完成了 GC,若完成了,线程就继续执行,否则就必须等待直到收到可以安全离开 Safe Region 的信号为止。


Java 是如何确定哪些对象是 GC Roots 的

查找 GC Roots 的方法有两种:

  • 保守式 GC:遍历方法区和栈区进行查找。也就是枚举所有的 GC Roots,包括常量、静态变量等全局性引用,以及执行上下文(栈帧)中的本地变量表中的变量

  • 准确式 GC:使用数据结构来记录 GC Roots 的引用位置

Hotspot 使用的是准确式 GC 的方式,采用的数据结构为 OopMap。


什么是 OopMap

在源码中每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。所以会出现无法确定某个位置里的内容是指针还是整型的“疑似指针”问题,OopMap 的出现是为了解决“疑似指针”问题。

在 Hotspot 中,对象的类型信息里有记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。从而可以完全确定某个对象上记录的类型信息。


抢先式中断和主动式中断

有两种方案来让所有线程在 GC 发生时都运行到最近的安全点上:

  • 抢先式中断:在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它执行到安全点
  • 主动式中断:设置一个标志,各个线程执行时主动去轮询这个标志,发现标志为真时就自己中断挂起。这个标志设置在安全点处以及创建对象需要分配内存的地方

在 Hotspot 中采用的是主动式中断。


Exception 和 Error 的区别

首先两者都是继承自 Throwable,只有 Throwable 类才能被抛出(throw)或者捕获(catch)。

Exception 和 Error 主要体现了 Java 平台设计者对不同异常情况的分类:Exception 被认为是程序正常运行中,可以预料的意外情况,因此应该被捕获并进行相应处理;Error 是指在正常情况下,不大可能出现的情况,通常不需要被捕获处理。

Exception 又分为可检查(checked)异常和不检查(unchecked)异常。可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分;不检查异常就是所谓的运行时异常,可以根据需要来决定是否捕获。


NoClassDefFoundError & ClassNotFoundException

有了对 Exception 和 Error 的基本认知之后,可以大概分析出 NoClassDefFoundError & ClassNotFoundException 两者的区别了。

NoClassDefFoundError 是一种 Error,由 JVM 产生。原因在于 JVM 运行时的 classloader 在 classpath 下找不到需要的类定义。通常造成该 Error 的原因是打包过程中漏掉了部分类,或者 jar 包出现损坏或篡改。

ClassNotFoundException 是属于 Exception 中的运行时异常。通常引发该异常的原因大多是因为使用 Class.forName() 方法动态的加载类,但是这个类在类路径中并没有被找到。


为什么不推荐使用 finalize 来实现清理

首先,要明确 finalize 是什么时候被调用的?finalize 是在 JVM 对该对象进行 GC 的时候被调用的。而 JVM 什么时候会对该对象执行 GC 是无法预估的,也就是如果将清理工作放到 finalize 来做,清理的时机我们无法把握。

你可能会说,如果手动调用 System.gc(); (实际上不会有人这样做)或者 System.runFinalization(); (实际上也不会有人这样做)的话是否能解决?事实上这两个方法仍然只是向 JVM 提交“申请”,并不确保能够会被马上执行。

即使我们能够解决调用时机的问题,使用 finalize 仍会带来 GC 的效率问题:

  • 如果一个对象没有实现 finalize 方法,那么在 GC 时,JVM 会认为该对象并无特别,会瞬间将该对象进行回收(快速 GC)
  • 如果一个对象实现了 finalize 方法,那么 JVM 会将该对象添加到 finalizer 执行队列,由专门的线程(FinalizerThread)来执行队列里面的对象中 finalize 方法,只有 finalize 方法被执行了(从 finalizer 执行队列里移除),该对象才会被回收

JVM 执行 YGC 是很频繁且迅速的,如果一个对象实现了 finalize 方法的话,存活时间将会比其他相同类型但是没有实现 finalize 方法的对象要长得多(可能都经过了几轮的 YGC,该对象还没被回收),表现为该对象对内存资源的占用时间很长。

FinalizerThread 的线程优先级很低,因此不会有太多机会被执行,这也是是 finalize 无法保证会被及时执行的原因。


GC 是如何实现对重写了 finalize 的对象进行延迟释放的

现在我们知道,如果一个对象实现了 finalize ,会在 finalize 方法被 FinalizerThread 调用之前一直存活。

那么问题是 GC 是如何实现对重写了 finalize 的对象进行延迟释放的呢?

你可能会认为简单啊,在对象中使用一个标识位标记该对象是否实现了 finalize 方法,在 GC 的时候进行检查即可。

确实,在 JVM 中确实存在一个 has_finalizer_flag 标识位来标记该对象是否是实现了 finalize 方法。但是细想一下,如果 GC 简单采用检查标记来实现延迟释放的话,至少会带来以下问题:

  1. 需要在 GC 的时候,对每个对象的标记位进行检查。而大多数对象是不会实现 finalize 方法的,会因为少数的实现 finalize 方法的对象而拖慢了大多数对象的释放速度
  2. 在运行完对象的 finalize 方法之后,为确保之后的 GC 能够回收该对象,还需要一个对标识位进行修改的逻辑

尤其是第一点,会严重拖慢 GC 的速度。所以 GC 并不采用简单检查标识符的方式,而是在原有的机制上实现对 finalize 对象的延迟释放。什么是原有机制,想想 GC 是如何确定一个对象是否能够被回收的,根据对象的根可达性来判定,简单来说就是根据该对象是否有“有意义”的引用指向它来判断,所以 JVM 巧妙的使用了这一点:为实现了 finalize 的对象提供一个“额外引用”,在执行了执行完 finalize 方法之后,释放该引用,来确保对象被正常回收,而这个应用是由 FinalizerThread 中的 ReferenceQueue 队列(链表实现)所提供的,完整的 finalize 机制应该是:

  1. 对象在初始化的过程中,会根据 has_finalizer_flag 标识位判断是否重写了 finalize

  2. 如果重写了 finalize,那就把当前对象注册到 FinalizerThread 的 ReferenceQueue 队列中,并调用 register_finalizer 方法,让该对象成为 Finalizer(这时候 GC 就会因为该对象有这样一个“额外引用”,而认为该对象可达,不释放该对象)

  3. 对象开始被调用,FinalizerThread 线程负责从 ReferenceQueue 队列中获取 Finalizer 对象。开始执行对象的 finalize 方法。在执行完成之后,将这个 Finalizer 对象从队列中移除(这时候该对象就会失去这个“额外引用”,GC 认为该对象不可达,从而释放对象)


Java 优化技术

除了 JIT 编译以外,Java 的优化技术包括 方法内联逃逸分析

  • 方法内联:由于方法的调用本质是新建一个栈帧压入线程中的 JVM Stack,在执行完成后拿到栈帧的返回值地址中的内容后,将栈帧弹出

  • 逃逸分析:判断一个对象是否被外部方法引用或外部线程访问的分析技术,根据分析结果来做不同的优化。包括栈上分配、锁消除、标量替换等等。

    • 栈上分配:Java 中的对象默认都是在堆上创建,然后在栈上用一个变量将对象存起来。当方法执行完成后,栈上变量会随着栈帧弹出而被马上回收,但是堆上的对象则要等待 GC 回收。所以如果逃逸分析发现一个对象只在方法中使用,就会将对象分配在栈上,这样省去了堆内存寻址和 GC 回收不及时问题。遗憾的是在 JDK 1.8 上并没有这个优化。

    • 锁消除:如果逃逸分析发现该方法并没有被多个线程执行,不存在多线程问题。那么会对方法中用到的锁进行消除,以减少获取锁释放锁的性能消耗。除了 JUC 里面的各种锁会被消除以外,还包括 StringBuffer、AtomicLong 等各种线程安全类的内部锁也会被消除。

    • 标量替换:如果如果逃逸分析发现一个对象不会被外部使用,并且这个对象可以被拆分,那么当程序执行的时候可能就不会创建该变量,转而创建该对象所需要用到的属性:

      1
      2
      3
      4
      5
      6
      public static void main(String[] args) {
      Foo foo = new Foo();
      foo.name = "name";
      foo.age = 18;
      // do something...
      }

      会转换为:

      1
      2
      3
      4
      5
      public static void main(String[] args) {
      String name = "name";
      int age = 18;
      // do something...
      }


HashMap 为什么不直接使用 key 的 hashCode 来决定键值对所在的下标

在以前学习 Java 之前就知道哈希表的底层还是数组。所以对应到 Java ,自然而然的想到 HashMap 应该是使用 key 的 hashCode 来取余得到对应的数组 index。

但是在读 HashMap 的源码的时候发现,HashMap 并没有直接使用 key 的 hashCode,而是将 key 的 hashCode 的高位数据移位到低位再进行异或运算,得到最终使用的哈希值:

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

采用这种做法主要是因为有些数据计算出的哈希值差异主要在高位,这样处理后可以有效避免哈希碰撞。


Lambda 表达式是如何实现的

Lambda 表达式是通过内部类来实现的:先在编译器创建一个私有静态方法,这个静态方法的实现就是我们 Lambda 表达式里要做的事情,然后在运行时再动态创建一个内部实现类来调用这个静态方法。

操作系统和内核的关系 内存区域划分

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×