文章

Java 面经

Java 面经

JIT

Java代码的执行流程:

image

我们需要格外注意的是 .class->机器码​ 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),这部分代码的机器码应该保存下来运行,因此引入了JIT。

JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。

HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。

AOT

即Ahead of Time Compilation,在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。但AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等,也就是无法用到AOP、Spring等,所以目前只适用于云原生场景。

包装类与它的缓存机制

基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。

Java 基本数据类型的包装类型的大部分都用到了缓存机制(提前创建好对象)来提升性能。

Byte​,Short​,Integer​,Long​ 这 4 种包装类默认创建了数值 [-128,127]的相应类型的缓存数据,Character​ 创建了数值在 [0,127] 范围的缓存数据,Boolean​ 直接返回 True​ or False​。

如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。

Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);

因此这个代码输出false。

如何解决浮点数运算的精度丢失问题?

用BigDecimal,通过整型来算小数,占更多内存,但不损失精度。

重写与重载

重写Override:子类与父类方法名、参数列表、返回类型都一样的方法,会覆盖父类的。

重载Overload:同一个类中参数列表不同的方法名字相同叫重载。

构造方法不能被重写(override),但可以被重载(overload)。因为子类的名字与父类不同,所以不一样,不叫重写。

浅拷贝与深拷贝

什么是拷贝?一个类实现了Cloneable​接口就可以调用.clone()​方法,把一个对象复制一遍。

例如:

public class Person implements Cloneable{
    public String pname;
    public int page;
    public Address address;
    public Person() {}
}
    Person p1 = new Person("zhangsan",21);
    p1.setAddress("湖北省", "武汉市");
    Person p2 = (Person) p1.clone();
    System.out.println("p1:"+p1);
    System.out.println("p1.getPname:"+p1.getPname().hashCode());
  
    System.out.println("p2:"+p2);
    System.out.println("p2.getPname:"+p2.getPname().hashCode());

可以看到p1和p2的对象地址是不一样的,内容是一样的,因此拷贝了一遍。

但是,Person类里面还有一些属性,例如String对象、其他类对象,它们是引用实例,在默认实现下它们不会被真的复制,复制的是引用。例如:

	p2.setAddress("湖北省", "荆州市");
    System.out.println("将复制之后的对象地址修改:");
    p1.display("p1");
    p2.display("p2");

看到在修改了p2之后p1也被改了,这种就是浅拷贝。当然这种情况下基本类型(int之类的)的实例不会这样,他们不是引用类型,是直接复制一遍。

深拷贝就容易理解了,引用对象也都要真的复制一遍。如何实现?

让每个引用类型属性内部都重写clone()​方法:既然引用类型不能实现深拷贝,那么我们将每个引用类型都拆分为基本类型,分别进行浅拷贝。比如上面的例子,Person 类有一个引用类型 Address(其实String 也是引用类型,但是String类型有点特殊,后面会详细讲解),我们在 Address 类内部也重写 clone 方法。

另外,我们也可以我们可以利用对象的序列化产生克隆对象,然后通过反序列化获取这个对象。

==与equals()

==:

基本类型:比较值

引用类型:比较地址

equals():

如果没重写:调用Object类的方法,直接比较引用地址

如果重写:按规则返回

HashCode

是什么

每个对象都有hashCode()​方法,hashCode不一定跟地址有关,默认情况下跟地址无关。

hashCode()​ 定义在 JDK 的 Object​ 类中,这就意味着 Java 中的任何类都包含有 hashCode()​ 函数。

另外需要注意的是:Object​ 的 hashCode()​ 方法是本地方法,也就是用 C 语言或 C++ 实现的。

有什么用

当你把对象加入 HashSet​ 时,HashSet​ 会先计算对象的 hashCode​ 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode​ 值作比较,如果没有相符的 hashCode​,HashSet​ 会假设对象没有重复出现。但是如果发现有相同 hashCode​ 值的对象,这时会调用 equals()​ 方法来检查 hashCode​ 相等的对象是否真的相同。

重写equals()必须重写hashCode()

两个相等的对象的 hashCode​ 值必须是相等。也就是说如果 equals​ 方法判断两个对象是相等的,那这两个对象的 hashCode​ 值也要相等。

如果重写 equals()​ 时没有重写 hashCode()​ 方法的话就可能会导致 equals​ 方法判断是相等的两个对象,hashCode​ 值却不相等。

Checked Exception 和 Unchecked Exception

image

除了RuntimeException​及其子类以外,其他的Exception​类及其子类都属于受检查异常 。

受检查异常就是必须要try-catch捕获或者向上级抛出,不受检查异常就是RuntimeException​,可以直接抛出的,即使不处理也可以通过编译,当然也可以捕获。


另外,try-catch有一个注意事项:

注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。

另外,在以下 2 种特殊情况下,finally​ 块的代码也不会被执行:

  1. 程序所在的线程死亡。
  2. 关闭 CPU。

泛型

泛型一般有三种使用方式:泛型类泛型接口泛型方法

泛型类好说,就是简单的用一个标识替代具体的类型,这个标识通常是T。

泛型接口:

public interface Generator<T> {
    public T method();
}

这个时候,可以根据类型做实现类

class GeneratorImpl implements Generator<String> {
    @Override
    public String method() {
        return "hello";
    }
}

当然也可以不指定类型

class GeneratorImpl<T> implements Generator<T>{
    @Override
    public T method() {
        return null;
    }
}

反射

通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。它赋予了我们在运行时分析类以及执行类中方法的能力。

动态代理、注解都依赖反射机制。

注解

Annotation (注解) 是 Java5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。

Java SPI

什么是SPI?简单来说,就是由功能调用方自己定义接口,然后由服务提供方根据接口去做实现,即所谓Service Provider Interface,专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

而这种一般应用时这个服务提供方可能是动态的,在运行时进行的。

Java中的SPI

关键是一个类:ServiceLoader。

先从使用的层面看,例如我们要定义一个log服务,然后利用SPI实现它。

package edu.jiangxuan.up.spi;

public interface Logger {
    void info(String msg);
    void debug(String msg);
}

先定义一个Logger接口,这个就是以后要由专门的服务实现的。

这是Service,是这个接口的调用者:

package edu.jiangxuan.up.spi;

import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;

public class LoggerService {
    private static final LoggerService SERVICE = new LoggerService();

    private final Logger logger;

    private final List<Logger> loggerList;

    private LoggerService() {
        ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
        List<Logger> list = new ArrayList<>();
        for (Logger log : loader) {
            list.add(log);
        }
        // LoggerList 是所有 ServiceProvider
        loggerList = list;
        if (!list.isEmpty()) {
            // Logger 只取一个
            logger = list.get(0);
        } else {
            logger = null;
        }
    }

    public static LoggerService getService() {
        return SERVICE;
    }

    public void info(String msg) {
        if (logger == null) {
            System.out.println("info 中没有发现 Logger 服务提供者");
        } else {
            logger.info(msg);
        }
    }

    public void debug(String msg) {
        if (loggerList.isEmpty()) {
            System.out.println("debug 中没有发现 Logger 服务提供者");
        }
        loggerList.forEach(log -> log.debug(msg));
    }
}

注意这里面这一句:ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);​这里获得了一个ServiceLoader,后面用迭代器遍历它,把Java通过SPI发现的所有Log的实现类全部装入这里面的loggerList变量。

此时如果没有其他东西,直接这样用,就会报错:

package edu.jiangxuan.up.service;

import edu.jiangxuan.up.spi.LoggerService;

public class TestJavaSPI {
    public static void main(String[] args) {
        LoggerService loggerService = LoggerService.getService();
        loggerService.info("你好");
        loggerService.debug("测试Java SPI 机制");
    }
}

info 中没有发现 Logger 服务提供者
debug 中没有发现 Logger 服务提供者

然后我们随便写一个Logger的实现类(即SPI概念的服务提供者):

package edu.jiangxuan.up.spi.service;

import edu.jiangxuan.up.spi.Logger;

public class Logback implements Logger {
    @Override
    public void info(String s) {
        System.out.println("Logback info 打印日志:" + s);
    }

    @Override
    public void debug(String s) {
        System.out.println("Logback debug 打印日志:" + s);
    }
}

然后像这样注册Services:

image

在 src 目录下新建 META-INF/services 文件夹,然后新建文件 edu.jiangxuan.up.spi.Logger (SPI 的全类名),文件里面的内容是:edu.jiangxuan.up.spi.service.Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)。

这样再次运行上面的测试,结果就会是:

Logback info 打印日志:你好
Logback debug 打印日志:测试 Java SPI 机制

ServiceLoader类原理

有点复杂,基本上可以分成两个部分:类加载器部分+为适配迭代器所做的部分。原始代码过于复杂,贴一下简化版本:


    // 关键方法,加载具体实现类的逻辑
    private void doLoad() {
        try {
            // 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名
            Enumeration<URL> urls = classLoader.getResources("META-INF/services/" + service.getName());
            // 挨个遍历取到的文件
            while (urls.hasMoreElements()) {
                // 取出当前的文件
                URL url = urls.nextElement();
                System.out.println("File = " + url.getPath());
                // 建立链接
                URLConnection urlConnection = url.openConnection();
                urlConnection.setUseCaches(false);
                // 获取文件输入流
                InputStream inputStream = urlConnection.getInputStream();
                // 从文件输入流获取缓存
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
                // 从文件内容里面得到实现类的全类名
                String className = bufferedReader.readLine();

                while (className != null) {
                    // 通过反射拿到实现类的实例
                    Class<?> clazz = Class.forName(className, false, classLoader);
                    // 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例
                    if (service.isAssignableFrom(clazz)) {
                        Constructor<? extends S> constructor = (Constructor<? extends S>) clazz.getConstructor();
                        S instance = constructor.newInstance();
                        // 把当前构造的实例对象添加到 Provider的列表里面
                        providers.add(instance);
                    }
                    // 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。
                    className = bufferedReader.readLine();
                }
            }
        } catch (Exception e) {
            System.out.println("读取文件异常。。。");
        }
    }

IO流

IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。

image

Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

内存模型

JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。

这里必须引入一个概念——多核情况下的缓存一致性问题。现代CPU都是多核的,CPU每个核心都有自己的Cache,那么就必须有一个机制来保证多核并发情况下的缓存一致性问题。

这个地方往往是操作系统和CPU共同解决,操作系统已经完整的解决了这个问题。

但是由于Java的跨平台特性,如果直接使用操作系统的内存模型,会因为不同操作系统或CPU的内存模型差异,在执行同一段字节码的时候会出现问题。因此Java需要提供自己的内存模型抽象。

这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。

image

happens-before 原则是什么?

happens-before是一个抽象出来的概念,具体是指使用一个逻辑时钟来区分事件的前后关系,来避免错误的指令重排。

在Java中,具体是指,Java规定了一些规则,在这些规则下,如果 指令重排/缓存不一致问题 符合这些条件,就要采取禁止重排,强制刷新等策略来规避预期外的运行结果。


著作权归JavaGuide(javaguide.cn)所有
基于MIT协议
原文链接:https://javaguide.cn/java/concurrent/jmm.html

License:  CC BY 4.0