Java知识难点总结一、基础篇

594 阅读10分钟
首图

Java知识难点总结

一、基础篇

1、八种基本数据类型的大小,以及他们的封装类

基本类型大小(字节)默认值封装类
byte1(byte) 0Byte
short2(short) 0Short
int40Integer
long80LLong
float40.0fFloat
double80.0dDouble
boolean-falseBoolean
char2\u0000(null)Character

注:在 Java虚拟机中没有任何供boolean值专用的字节码指令,Java语言表达式所操作的boolean值,在编译之后都使用int数据类型来代替,而boolean数组将会被编码成Java虚拟机的byte数组,每个元素boolean元素占8位。从而可以得出boolean类型单独使用时是4个字节,在数组中又是1个字节。

使用int的原因是?

知识点1:局部变量表存放编译期可知的各种基本数据类型(8种),引用类型(reference),return address 类型。

局部变量表中最基本的存储单元是slot: 32位占用一个slot,64位类型(long和double)占用两个slot。

知识点2:Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈。栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度。这就解释了为什么:byte、short与int做四则运算时要转换为int,是因为操作数栈是以一个slot进行加载的!

2、Java的自动装箱与拆箱

自动装箱和拆箱是一种语法糖,它说的是八种基本数据类型的包装类和其基本数据类型之间的自动转换。简单的说,装箱就是自动将基本数据类型转换为包装器类型;拆箱就是自动将包装器类型转换为基本数据类型。

先来了解一下基本数据类型的包装类都有哪些:

image-20211103160405931

也就是说,上面这些基本数据类型和包装类在进行转换的过程中会发生自动装箱/拆箱,例如下面代码:

Integer integer = 66; // 自动拆箱

int i1 = integer;   // 自动装箱

原理探究:那么自动拆箱和自动装箱是如何实现的呢?

其实这背后的原理是编译器做了优化。将基本类型赋值给包装类其实是调用了包装类的 valueOf() 方法创建了一个包装类再赋值给了基本类型。

image-20211103161615441

而包装类赋值给基本类型就是调用了包装类的 xxxValue() 方法拿到基本数据类型后再进行赋值。

image-20211103161807080

通常在无法了解代码是如何实现或工作的时候,可以通过查看字节码进行查看,我在看字节码的时候就会有一种豁然开朗的感觉!

(面试题)以下代码会输出什么?

public class AutoParking {
    public static void main(String[] args) {
        Integer i1 = 100;
        Integer i2 = 100;
        Integer i3 = 200;
        Integer i4 = 200;
        System.out.println(i1==i2);
        System.out.println(i3==i4);
    }
}

可以先自己尝试解答一下再看答案,如果答错了就会有一种强烈的愿望想知道为什么!

运行结果:
true
false

结果分析:

对于对象的==判断,平常我们的想法是对象的地址是否一致,对于这道题也是同样的道理,Integer是引用类型,比较的是i1所指向的地址是否与i2相等,那为什么会一个结果为true,而另一个为false呢?原因还是在上面提到过的知识点里,将基本类型赋值给包装类其实是调用了包装类的 valueOf() 方法创建了一个包装类再赋值给了基本类型。

我们去Integer的valueof()源码里看看具体实现:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

其中IntegerCache类的实现为:

image-20211103163803850

从这2段代码可以看出,在通过valueOf方法创建Integer对象的时候,如果数值在[-128,127]之间,便返回指向IntegerCache.cache中已经存在的对象的引用;否则创建一个新的Integer对象。

3、a=a+b与a+=b的区别

当我们执行以下代码时,会发生什么情况?

public class AutoConversion {
    public static void main(String[] args) {
        byte a = 127;
        byte b = 127;
        b = a + b;
    }
}

image-20211104102137731

毫无疑问,编译都不会通过,我们知道,当byte,short进行四则运算时会转换为int进行计算,a + b的结果为int,将它赋值给byte会报错,除非进行强制类型转换。

下面我们再来看看执行以下代码时,又会发生什么?

public class AutoConversion {
    public static void main(String[] args) {
        byte a = 127;
        byte b = 127;
        b += a;
        System.out.println(b);
    }
}

或许有些人认为这个代码与上一个有啥区别呢?

下面来看看执行结果:
-2

+= 操作符会进行隐式的自动类型转换,此处b += a隐式的将加操作的结果类型强制转换为持有结果的类型。

byte 取值范围

Java中,byte在内存中占一个字节,取值范围为-128 - 127,计算机是用二进制来表示数据的,一个字节也就是8个比特位,其中最高位表示符号位(0正1负)

故byte的取值范围为1000 0000 到 0111 1111

在Java中,是采用补码来表示数据的,正数的补码和原码相同,负数的补码是在原码的基础上各位取反然后加1

127 + 127 = 254,其二进制为1111 1110,取反再加1为0000 0010,因为为负数,所以结果为-2

4、深入解析单例模式的5种实现原理

首先我们需要了解一下单例模式的四大原则:

  1. 构造方法私有
  2. 以静态方法或者枚举返回实例
  3. 多线程环境下都是访问同一个实例
  4. 反序列化时不会重新构建对象

单例模式通常有5种实现方法:

  • 饿汉模式
  • 懒汉模式
  • 双重锁懒汉模式(DCL)
  • 静态内部类模式(常用)
  • 枚举模式(《Effective Java》推荐)

下面我们来一一了解这5种方式实现的原理

1、饿汉模式

public class SingleTon {
    // 提前实例化
    private static SingleTon INSTANCE = new SingleTon();
    // 构造方法私有
    private SingleTon(){}
    
    public static SingleTon getInstance() {
        return INSTANCE;
    }
}

饿汉模式会在类初始化时就提前创建了对象,是一种以空间换取时间的方法,所以不存在线程安全问题。(记忆方法:一个人很饿,那他就会提前把食物准备好)

2、懒汉模式

public class SingleTon {
    private static SingleTon INSTANCE = null;
    private SingleTon() {}

    public static SingleTon getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingleTon();
        }
        return INSTANCE;
    }
}

懒汉模式会在方法被调用的时候才去创建对象,以时间换取空间,在多线程下存在线程安全问题,当多个线程调用getInstance()方法时可能都看到INSTANCE = null,从而导致多个线程去创建对象,违背单例模式的原则。

3、双重锁懒汉模式(DCL)

public class SingleTon {
    private static SingleTon INSTANCE = null;
    private SingleTon() {}

    public static SingleTon getInstance() {
        if (INSTANCE == null) {
            synchronized (SingleTon.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SingleTon();
                }
            }
        }
        return INSTANCE;
    }
}

DCL模式是对懒汉模式的改进,第一次判断 INSTANCE == null是为了避免非必要加锁,只有在第一次加载的时候才会对Class对象进行加锁再实例化,这样既可以节约内存空间,又能保证线程安全。但是因为 JVM 存在指令重排的功能,DCL也会存在线程不安全的情况。原因如下:

INSTANCE = new SingleTon();

实例化对象看似只有一行代码,但其实在 JVM 中是分为三步执行的:

  1. 在堆内存中开辟内存空间;
  2. 在分配的内存空间中实例化SingleTon的各个参数;
  3. 将对象指向堆内存空间。

由于 JVM 的指令重排原因,所以有可能2还没执行就先执行了3,如果此时再被切换到线程B上,由于执行了3,INSTANCE != null 已经非空了,会被直接拿出来用,这样的话,就会出现异常。这个就是著名的DCL失效问题。

具体解决方案是使用 volatile 关键字,可以防止指令重排,从而解决DCL失效问题。

private volatile static SingleTon INSTANCE = null;

4、静态内部类模式

public class SingleTon {
    private SingleTon() {}
    private static class SingleTonHolder {
        private static SingleTon INSTANCE = new SingleTon();
    }

    public static SingleTon getInstance() {
        return SingleTonHolder.INSTANCE;
    }
}

使用静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不用去初始化INSTANCE,故而不占内存。即当SingleTon第一次被加载时,并不需要去加载SingleTonHolder类,只有当获取INSTANCE实例,也就是getInstance()方法第一次被调用时,才会导致虚拟机加载SingleTonHolder类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

那么,静态内部类又是如何实现懒加载,又如何实现线程安全的呢?首先我们需要先去了解一下类的加载时机。(以下内容来自《深入理解JAVA虚拟机》第7.2节 类的加载时机,也可以去看我的另一篇博客

对于初始化阶段,《Java虚拟机规范》严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段的时候。(被final修饰、已在编译期把结果放入常量池的静态字段除外)
    • 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语 ——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。而静态内部类就属于被动引用的行列,从而解释为什么外部类加载的时候静态内部类并不会被加载。

那么INSTANCE在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》7.3.5节 初始化中,有这么一句话:

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会由其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。

从而,可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例既可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

缺点:由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,只能调用固定的构造函数。

5、枚举模式

在《effective java》第83 条:慎用延迟初始化(这本书真的很棒)中说道,最佳的单例实现模式就是枚举模式。利用枚举的特性,让JVM来帮我们保证线程安全和单一实例的问题。除此之外,写法还特别简单,具体可以查看我的另一篇博客

public enum SingleTon {
    INSTACE;
    public void doSomething() {
        System.out.println("doSomething");
    }
}
// 调用方法:
class Main {
    public static void main(String[] args) {
        SingleTon.INSTACE.doSomething();
    }
}