一、Java基础

1.1 String、StringBuffer、StringBuilder的区别是什么?


可变性

String类中使用final关键字修饰字符数组来保存字符串,所以String对象是不可变的。

  1. pirvate final char value[];
  2. //在Java9之后,String类的实现改用byte数组存储字符串
  3. private final byte[] value;

StringBuilder与StringBuffer都继承AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串char[] value,但是没有用final关键字修饰,所以这两种对象都是可变的

线程安全性

  1. String中的对象是不可变的,也就可以理解为常量,线程安全。
  2. StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
  3. StringBuilder并没有对方法加同步锁,所以是非线程安全的

    性能

  4. 每次对String类型进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String对象。

  5. StringBuilder和StringBuffer每次都会对对象本身进行操作,而不是生成新的对象。
  6. StringBuilder的性能比StringBuffer的性能要好,但是会冒多线程不安全的风险。

    使用总结

  7. 操作少量的数据:适用String

  8. 单线程操作字符串并操作大量数据:适用StringBuilder
  9. 多线程操作字符串并操作大量数据:使用StringBuffer

1.2 泛型的意义和作用

为什么需要泛型

通过泛型可以定义类型安全的数据结构,这能显著提高性能和得到更高质量的代码。

不使用泛型存在的问题

  • 性能问题: 如果是使用值类型,会存在装箱和拆箱的操作,装箱和拆箱操作都会根据它们自己的权限造成重大性能损失,还会导致更多的垃圾收集工作。即使是使用引用类型而不是值类型时,仍然存在性能损失,因为必须从Object向你要与之交互的实际类型进行强制类型转换,从而造成强制类型转换开销。
  • 类型安全问题: 因为编译器允许任何类型和Object之间进行强制类型转换,所以会丢失编译时的类型安全,存在引发无效强制类型转换异常的风险。

    泛型的类型擦除

    Java代码编译成功之后,程序会采取去泛型化的措施,也就是说,Java中的泛型,只在编译阶段有效,在编译过程中,检验泛型结果正确后,会将泛型的相关信息擦除,并且在对象进入和离开方法边界处添加类型检查和类型转换的方法,也就是说,成功编译过后的class文件中是不包含任何泛型信息的。泛型信息不会进入到运行时阶段。

    1.3 equals()方法和hashCode方法为什么要重写

    当我们使用 HashMap 存放自定义的类的时候,就必须要重写 equals()hashCode() 方法,不重写的话,那么两个相同的对象可能就会被放到HashMap的不同的位置。

为什么重写equals()方法时必须重写hashCode()方法:例如有两个对象equals()是相等的,但是由于没有重写hashCode(),那么就会被放到不同的位置,从而导致相等的对象无法碰见一起,因此会影响去重等操作。

如果不使用HashMap或者HashSet或者equals()方法,就可以不用重写equals()或者hashCode()方法,在Object类中,equals()是比较两个对象的地址是否相等。

1.4 什么是面向对象,你是怎么理解的?

面向对象是对现实世界的理解和抽象的方法 ,可以举例把大象塞进冰箱的例子来说明面向对象和面向过程的区别。

面向对象的特征:封装、继承、多态、抽象(这个有些时候不需要答)
面向对象的优点:面向对象易维护、易复用、易拓展;

易维护: 每个类分工明确,所以容易定位问题 易复用: 由于类与类之间可以继承,所以可以重用写过的代码 易拓展: 子类可以在父类的基础上添加新的功能。

1.5 String的intern()方法

二、Java关键字

2.1 volatile关键字

作用

1. 保证变量的内存可见性

(1)可见性问题是怎么产生的?
  • 因为每个线程都有自己的工作内存,线程的工作内存保留了被线程使用的变量的工作副本。
  • 不同线程不能直接访问对方的工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

正是因为上述原因,会导致线程对共享变量的修改没有及时更新到主内存中,或者线程没能及时将共享变量的最新值同步到自己的工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。所以这就是 内存可见性问题

(2)内存可见性?

内存可见性是指当一个线程修改了某个变量的值,其他线程总是能知道这个变量的变化,也就是说线程A修改了共享变量V的值,那么线程B在使用V的值时,能立即读取到V的最新值。

(3)可见性问题的解决方案

①加锁
使用synchronized进行加锁,因为当一个线程进入到synchronized代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行完代码块后,又将修改后的副本值刷新到主内存中,最后线程释放锁。
②使用volatile关键字
使用volatile修饰共享变量后,每个线程要操作变量时,会从主内存中将变量拷贝到工作内存作为副本,当线程操作共享变量副本并写回到主内存后,会通过 CPU总线嗅探机制 告知其他线程该变量副本已经失效,需要重新从主内存中读取。
volatile保证了不同线程对共享变量操作的可见性,也就是当一个线程修改了volatile修饰的变量,当修改后的变量写回主内存时,其他线程能立刻看到最新值。

(4)CPU总线嗅探机制

在现代计算机中,CPU的速度是极高的,如果CPU需要存取数据时,都直接与内存打交道的话,那么在存取过程中,CPU将一直空闲,这是一种极大的浪费,所以为了提高处理速度,CPU不直接和内存进行通信,而是在CPU与内存之间加入很多寄存器,多级缓存,他们比内存的读取速度高得多,这样就解决了CPU运算速度和内存读取速度不一致的问题。
由于CPU与内存之间加入了缓存,所以在进行数据操作时,会先将数据从内存拷贝到缓存中,CPU直接操作的是缓存中的数据,在多级处理器下,将可能导致各自的缓存数据不一致(这就是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而 嗅探是实现缓存一致性的常见机制
缓存一致性问题不是多处理器导致的,而是多缓存导致的。
嗅探机制工作原理: 每个处理器通过监听在总线上传播的数据来检查自己的缓存是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置无效状态,当处理器需要对这个数据进行操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意: 基于CPU缓存一致性协议,JVM实现了volatile的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用volatile会引起总线风暴,所以,volatile的使用要适合具体场景。

(5)可见性问题小结

使用volatile和synchronized锁都可以保证共享变量的可见性,相比synchronized而言,volatile可以看做是一个轻量级锁,所以使用volatile的成本更低,因为不会引起线程上下文的切换和调度,但volatile无法像synchronized一样保证操作的原子性。

2. 禁止指令重排序

(1)什么是重排序
为了提高性能,在遵守 as-if-serial 语义(即不管怎么重排序,单线程下的程序执行结果不能被改变,编译器runtime和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。

一般重排序分为如下三种类型:

  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在轮序执行。

    数据依赖性:如果两个操作访问同一个变量,并且这两个操作中有一个操作为写操作,此时两个操作之间就存在数据依赖性,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

从Java源代码到最终执行的指令数列,会分别经历下面三种重排序:
Java小知识点 - 图1

  1. int a = 0;
  2. //线程A
  3. a = 1; // 1
  4. flag = true; // 2
  5. //线程B
  6. if (flag) { // 3
  7. int i = a; // 4
  8. }

单看上面的程序,最后 i 的值是 1 ,但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序,假设线程A在执行时被重排序成先执行代码2,在执行代码1;而线程B在线程A执行完代码2后,读取了flag变量,由于条件判断为真,线程B将读取变量a,此时变量a还根本没有被线程A写入,那么i最后的值是0,导致结果不正确,所以可以使用volatile关键字禁止指令重排序。
volatile不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了volatile修饰的变量编译后的顺序和
执行顺序一样,那么使用volatile修饰flag变量后,在线程A中,保证了代码1的执行顺序在代码2之前。

volatile的原子性问题

此处所谓的 原子性 是指操作volatile修饰的共享变量时,volatile不能保证数据操作的原子性,从而导致可见性失效或者是来的有点晚,所以多线程环境下,使用volatile修饰的变量是线程不安全的。

例如i++过程分为三步,先获取i的值,再对值进行加1,最后将新的值返回。例如线程A首先得到了i的初始值为100,还没来得及修改,线程B也得到了i的值,为100,最终线程B将i值修改为101,线程A由于获取的i值为100,最后也将i值修改为了101,所以由于这种非原子操作,volatile是线程不安全的。

解决这个问题可以使用锁机制,或者使用原子类(如AtomicInteger)

对任意单个使用volatile修饰的变量的读/写是具有原子性的,但类似于flag = !flag这种复合操作就不具有原子性。简单来说就是,单纯的复制操作是具有原子性的。

volatile在单例模式中的应用

单例模式中的懒汉式双重检测模式中就使用到了volatile关键字(目的是禁止指令重排)

  1. public class Singleton {
  2. //volatile保证可见性和禁止指令重排序
  3. private static volatile Singleton singleton;
  4. private Singleton() {
  5. }
  6. public static Singleton getInstance() {
  7. //第一次检查
  8. if (singleton == null) {
  9. //同步代码块
  10. synchronized(Singleton.class) {
  11. //第二次检查
  12. if (singleton == null) {
  13. //对象的实例化是一个非原子性操作
  14. singleton = new Singleton();
  15. }
  16. }
  17. }
  18. return singleton;
  19. }
  20. }

上述代码中, new Singleton() 是一个非原子性操作,对象实例化分为三个部分,(1)分配内存空间。(2)初始化实例。(3)返回内存地址给引用。所以在使用构造器创建对象时,编译器可能会进行指令重排序。假设线程A在执行创建对象时,编译器可能会进行指令重排序,(2)和(3)进行了重排序,变成先(3)后(2),如果线程B在线程A执行(3)时,拿到了引用地址,并且在第一个检查中判断了 singleton != null ,但是此时B拿到的对象是一个不完整的对象,在使用该对象进行操作时就会出现问题。

总结

  • volatile修饰符适用以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,或者作为状态变量,如 flag == true ,实现轻量级同步。
  • volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以它是低成本的。
  • volatile只能作用于属性,我们用volatile修饰属性,这样编译器就不会对这个属性做指令重排序
  • volatile提供了可见性,任何一个线程对其的修改都将立马对其他线程可见,volatile属性不会被线程缓存,始终从主存中读取
  • volatile提供了happens-before保证,对volatile变量V的写入happens-before所有其他的线程后续对V的读操作
  • volatile可以使纯赋值操作时是原子的,如 boolean flag = true;
  • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

2.2 native关键字

什么是Native Method

native method是一个Java方法,该方法的实现由非Java语言实现。定义一个native method时,不提供实现体,因为其实现体是由非Java语言在外面实现的。
native method可以与除了abstract外其他的Java标识符连用,因为native暗示这些方法是有实现体的,只不过这些实现体是非Java的,但是abstract却指明这些方法无实现体,所以二者有冲突。native与其他Java标识符连用时,其意义同非native method并无差别。

为什么要使用Native Method

Java使用起来非常方便,然而有些层次的任务使用Java实现起来不容易。

  1. 与Java环境外交互:

有时Java应用需要和Java外面的环境交互,这是本地方法存在的主要原因,Java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,使我们无需去了解Java应用之外的繁琐细节。

  1. 与操作系统交互

由于JVM毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

三、Java集合

3.1 HashMap的底层实现

注意点

  1. 效率比 HashTable
  2. 线程不安全的,多线程的情况下可能会导致死循环
  3. null 可以作为键,这样子的键只能有一个,可以有一个或多个键对应的值为 null
  4. 如果事先知道需要多少容量,可以通过 (expectedCapacity / loadFactory) + 1 计算出应该将初始容量设置为多少。 expectedCapacity :需要的容量。 loadFactory :负载因子,默认值为0.75

    关键参数

  5. 构造函数中的参数:

    1. int initialCapacity :初始化容量;默认初始化大小为16,如果指定了初始化容量n,那么容量会变为大于或等于n的最小的2次方
    2. final float loadFactory :负载因子,默认值是0.75
  6. int size :当前Map中实际键值对的数量,当 size > threshold 时,就会调用 resize() 方法进行扩容
  7. int threshold :阈值, threshold = initialCapacity * loadFactory

    初始化过程

  8. 通过构造函数进行初始化时,会将 threshold 设置为用户自定义的容量

  9. 第一次执行 put 操作时,才开始扩容,开辟空间,将阈值 threshold 设置为 initialCapacity * loadFactory

    JDK1.7

    JDK1.8之前 HashMap 底层是 数组 + 链表 结合在一起的链表散列。

  10. HashMap 通过key的hashCode经过扰动函数处理过后得到hash值

  11. 通过 (n - 1) & hash 判断当前元素存放的位置(n指的是数组的长度)
  12. 如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同的话,就直接覆盖,如果不相同,就通过拉链法解决冲突。(JDK1.8采用的是尾插法,JDK1.7及以前是头插法)

    拉链法:链表和数组的结合,就是创建一个链表数组,数组中的每一格就是一个链表,如果遇到哈希冲突,就将冲突的值添加到链表中即可。

JDK1.8

JDK1.8在解决hash冲突时有了较大的变化,当链表长度大于8时,将链表转化为红黑树,以减少搜索时间。
TreeMap、TreeSet以及JDK1.8之后的HashMap底层都用到了红黑树

红黑树的作用:解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

为什么要使用扰动函数

使用扰动函数是为了防止一些实现较差的 hashCode() 方法,目的是减少碰撞。

为什么容量总是2的幂次方

因为取余操作中,如果除数是2的幂次方,那么就可以等价于与其除数 - 1的与(&)操作。并且采用二进制操作&,相对于%可以提高运算效率。

3.2 ArrayList和LinkedList的区别

  1. 从实现上来看: ArrayList底层实现是动态数组;LinkedList底层实现是双向链表;
  2. 从扩容机制上来看: ArrayList底层是动态数组,存在扩容的情况,默认的数组大小是10,在检测是否需要扩容后,会扩容为原来的1.5倍的大小,然后将老数组元素存储到新数组里面;LinkedList不存在扩容的说法,因为是链表结构;