Java面经

Java 面经

一、Java 基础

1、final 关键字的作用

  • 修饰引用:该引用为常量,其值无法修改
  • 修饰方法:该方法为最终方法,无法被子类重写
  • 修饰类:该类为最终类,无法被继承

2、String、StringBuffer、StringBuilder 的区别

  • String:值是不可变的,这就导致每次对String的操作都会产生新的String对象,效率低下且浪费内存
  • StringBuffer:值可变且线程安全
  • StringBuilder:值可变但线程不安全,速度较StringBuffer更快

3、String为什么要设计成不可变的

  • 防止被篡改,保证信息数据的安全性
  • 不变的对象和值是线程安全的
  • 哈希值的唯一性来提高性能
  • 提高常量池的可用性

4、抽象类和接口的区别

  • 抽象类是半抽象的,有构造方法,只允许出现常量和抽象方法。类和类之间只能单继承,一个抽象类只能继承一个类(单继承),多个功能的集成部分,可用抽象类(抽象类提供简单方法)
  • 接口是完全抽象的,没有构造方法,接口和接口之间支持多继承,一个类可以同时实现多个接口

5、static 关键字的作用

  • 修饰方法:静态方法不依赖于任何对象就可以进行访问,在静态方法中不能访问类的非静态成员变量和非静态成员方法
  • 修饰变量:静态变量被所有的对象所共享,在内存中只有一个副本,它当且仅当在类初次加载时会被初始化
  • 修饰代码块:在类初次被加载的时候,会按照static块的顺序来执行每个static块,并且只会执行一次

6、多态是什么

  • 多态分为两种:
    • 编译时多态(设计时多态):方法重载
    • 运行时多态:JAVA运行时系统根据调用该方法的实例的类型来决定选择调用哪个方法则被称为运行时多态
  • 多态存在的三个必要条件:
    • 要有继承(包括接口的实现)
    • 要有重写
    • 父类引用指向子类对象(向上转型)

7、Exception 和 Error 的区别

  • Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应的处理
  • Error 是指在正常情况下,不大可能出现的情况,绝大部分 Error 都会使程序处于非正常、不可恢复的状态。既然是非正常,所以不便于也不需要捕获,常见的 OutOfMemoryError 就是 Error 的子类

8、什么是反射机制?反射机制的应用场景有哪些?

  • 对于任意一个类,都能够获得这个类的所有属性和方法,对于任意一个对象都能够调用它的任意一个属性和方法。这种在运行时动态的获取信息以及动态调用对象的方法的功能称为Java 的反射机制
  • 应用场景:
    • 工厂模式
    • 注解
    • json解析
    • 动态代理

9、为什么重写 equals 还要重写 hashCode ?

hashCode 和 equals 两个方法是用来协同判断两个对象是否相等的,采用这种方式的原因是可以提高程序插入和查询的速度,如果在重写 equals 时,不重写 hashCode,就会导致在某些场景下,例如将两个相等的自定义对象存储在 Set 集合时,就会出现程序执行的异常,为了保证程序的正常执行,所以我们就需要在重写 equals 时,也一并重写 hashCode 方法才行

10、Java 泛型类型擦除和其局限性

  • Java的泛型是伪泛型,Java的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程称为类型擦除

  • 局限性:

    • 运行时类型查询只适用于原始类型

      查询一个对象是否属于某个泛型类型时,倘若使用 instanceof 会得到一个编译器错误, 如果使用强制类型转换会得到一个警告

    • 不能用基本类型实例化类型参数

      不能用类型参数代替基本类型。因此, 没有 Pair<int>, 只 有 Pair<Integer>。 当然,其原因是类型擦除。擦除之后, Pair 类含有 Object 类型的域, 而 Object 不能存储 int值

    • 不能实例化类型变量

      不能使用像 new T(…)、 newT[…] 或 T.class 这样的表达式中的类型变量

      1
      2
      3
      4
      5
      6
      7
      public class Singleton<T> {
      private static T singlelnstance; // Error
      public static T getSinglelnstance() { // Error
      if (singleinstance == null) construct new instance of T
      return singlelnstance;
      }
      }
    • 不能抛出或捕获泛型类的实例

      实际上, 甚至泛型类扩展 Throwable 都是不合法的;

      catch 子句中不能使用类型变量。例如, 以下方法将不能编译:

      1
      2
      3
      4
      5
      6
      7
      public static <T extends Throwable> void doWork(Class<T> t) {
      try{
      do work
      } catch (T e) { // Error can 't catch type variable
      Logger,global.info(...)
      }
      }
    • 不能创建参数化类型的数组

      例如:

      1
      Pair<String>[] table = new Pair<String>[10];

11、Java中引用类型的区别,具体的使用场景

  • 强引用: 强引用指的是通过 new 对象创建的引用。

    • 正常创建的对象,只要引用存在,永远不会被GC回收,即使OOM
    • 如果要中断强引用和某个对象的关联,为其赋值null,这样GC就会在合适的时候回收对象
    • Vector类的clear()方法就是通过赋值null进行清除
  • 软引用: 软引用是通过 SoftRefrence 实现的,它的生命周期比强引用短,在内存不足,抛出 OOM 之前,垃圾回收器会回收软引用引用的对象。软引用常见的使用场景是存储一些内存敏感的缓存,当内存不足时会被回收。

    • 在内存足够的情况下进行缓存,提升速度,内存不足时JVM自动回收
    • 可以和引用队列ReferenceQueue联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中
  • 弱引用: 弱引用是通过 WeakRefrence 实现的,它的生命周期比软引用还短,GC 只要扫描到弱引用的对象就会回收。弱引用常见的使用场景也是存储一些内存敏感的缓存。

    • ThreadLocalMap防止内存泄漏
    • 监控对象是否将要被回收
  • 虚引用: 虚引用是通过 FanttomRefrence 实现的,它的生命周期最短,随时可能被回收。如果一个对象只被虚引用引用,我们无法通过虚引用来访问这个对象的任何属性和方法。它的作用仅仅是保证对象在 finalize 后,做某些事情。虚引用常见的使用场景是跟踪对象被垃圾回收的活动,当一个虚引用关联的对象被垃圾回收器回收之前会收到一条系统通知。

    • 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动

12、单例模式

  • 双重检查单例模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private volatile static Singleton instance;
private Singleton(){}
public static Singleton getInstance(){
if (instance==null) {
synchronized (Singleton.class) {
if (instance==null){
instance=new Singleton();
}
}
}
return instance;
}
}

第一次校验

也就是第一个if(singleton==null),这个是为了代码提高代码执行效率,由于单例模式只要一次创建实例即可,所以当创建了一个实例之后,再次调用getInstance方法就不必要进入同步代码块,不用竞争锁。直接返回前面创建的实例即可。

第二次校验

也就是第二个if(singleton==null),这个校验是防止二次创建实例,假如有一种情况,当singleton还未被创建时,线程t1调用getInstance方法,由于第一次判断singleton==null,此时线程t1准备继续执行,但是由于资源被线程t2抢占了,此时t2页调用getInstance方法,同样的,由于singleton并没有实例化,t2同样可以通过第一个if,然后继续往下执行,同步代码块,第二个if也通过,然后t2线程创建了一个实例singleton。此时t2线程完成任务,资源又回到t1线程,t1此时也进入同步代码块,如果没有这个第二个if,那么,t1就也会创建一个singleton实例,那么,就会出现创建多个实例的情况,但是加上第二个if,就可以完全避免这个多线程导致多次创建实例的问题。

  • 静态内部类单例模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
在内部类被加载和初始化时,才创建INSTANCE
静态类不会自动随着外部类的加载和初始化而初始化的,它是要单独去加载和初始化的。
因为是在内部类加载和初始化时,创建的,因此是线程安全的
*/
public class Singleton {
private Singleton(){}
private static class Inner{
private static final Singleton instance =new Singleton();
}
public static Singleton getInstance(){
return Inner.instance;
}
}

13、类加载过程

Java 中类加载分为 3 个步骤:加载、链接、初始化。

  • 加载: 由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的java.lang.Class对象实例。数据源可以是 Jar 文件、Class 文件等等。如果数据的格式并不是 ClassFile 的结构,则会报 ClassFormatError

  • 链接:链接是类加载的核心部分,这一步分为 3 个步骤:验证、准备、解析

    • 验证: 验证是保证JVM安全的重要步骤。JVM需要校验字节信息是否符合规范,避免恶意信息和不规范数据危害JVM运行安全。如果验证出错,则会报VerifyError
      • 格式验证:验证是否符合class文件规范。
      • 语义验证:检查一个被标记为final的类型是否包含子类;检查一个类中的final方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(比如方法签名相同,但方法的返回值不同)
      • 操作验证:在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证(通常在解析阶段执行,检查是否可以通过符号引用中描述的全限定名定位到指定类型上,以及类成员信息的访问修饰符是否允许访问等)
    • 准备: 这一步会创建静态变量,并为静态变量开辟内存空间,并为其设置一个初始值(由于还没有产生对象,实例变量不在此操作范围内),被final修饰的static变量(常量),会直接赋值
    • 解析: 这一步会将符号引用替换为直接引用。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 初始化(先父后子): 初始化会为静态变量赋值,并执行静态代码块中的逻辑

    • 如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。

      因为子类存在对父类的依赖,所以类的加载顺序是先加载父类后加载子类,初始化也一样。不过,父类初始化时,子类静态变量的值也有有的,是默认值。

      image-20230315205319406

14、双亲委派模型

类加载器大致分为3类:启动类加载器、扩展类加载器、应用程序类加载器。

  • 启动类加载器主要加载 jre/lib下的jar文件。
  • 扩展类加载器主要加载 jre/lib/ext 下的jar文件。
  • 应用程序类加载器主要加载 classpath 下的文件。

所谓的双亲委派模型就是当加载一个类时,会优先使用父类加载器加载,当父类加载器无法加载时才会使用子类加载器去加载。这么做的目的是为了避免类的重复加载。

15、new一个对象要经历哪些过程

  • 首先Java在new一个对象的时候,会先查看对象所属的类有没有被加载到内存,如果没有的话,就会先通过类的全限定名将对象所属的.class文件加载到内存中。加载并初始化类完成后,再进行对象的创建工作。

  • 初始化类完成后,就是创建对象

    1. 在堆区分配对象需要的内存

      分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量。

    2. 对所有实例变量赋默认值

      将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值。

    3. 执行实例初始化代码

      初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法。

    4. 如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它需要注意的是,每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问。

    5. 补充:通过实例引用调用实例方法的时候,先从方法区中对象的实际类型信息找,找不到的话再去父类类型信息中找。

      如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要经过很多次查找。这时候大多系统会采用一种称为虚方法表的方法来优化调用的效率。

      所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。

二、Java 数据结构

1、HashMap 和 Hashtable 的区别

  • HashMap是线程不安全的,效率高;Hashtable是线程安全的,效率低;
  • HashMap可以存储null键和null值;Hashtable不可以存储null键和null值;

2、HashMap 底层原理

  • HashMap由数组(键值对entry组成的数组主干)+ 链表(元素太多时为解决哈希冲突数组的一个元素上多个entry组成的链表)+ 红黑树(当链表的元素个数达到8链表存储改为红黑树存储)进行数据的存储
  • 在JDK1.7之前,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个数组中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

3、HashMap 的扩容过程

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置

4、为什么 HashMap 使用红黑树而不用二叉平衡树(AVL)

  • 红黑树虽然不是严格的平衡树,但是其依旧是平衡树,查找效率是 **O(logn)**;AVL也是 O(logn)
  • 红黑树舍去了严格的平衡,使其插入,删除,查找的效率稳定在 **O(logn)**;反观 AVL 树,查找没问题 **O(logn)**,但是为了保证高度平衡,动态插入和删除的代价也随之增加,综合效率肯定达不到 O(logn)
  • 在进行大量插入,删除操作时,红黑树更优一些

5、TreeMap 底层原理

TreeMap底层通过红黑树实现,可保证在**O(logn)**完成get、put和remove操作,效率很高

6、Hashtable 怎么保证线程安全的

使用synchronized关键字修饰public方法保证线程安全

7、ConcurrentHashMap 原理

  • 在 JDK7 中,ConcurrentHashMap 使用“分段锁”机制实现线程安全,数据结构可以看成是”Segment数组+HashEntry数组+链表”,一个 ConcurrentHashMap 实例中包含若干个 Segment 实例组成的数组,每个 Segment 实例又包含由若干个桶,每个桶中都是由若干个 HashEntry 对象链接起来的链表。因为Segment 类继承 ReentrantLock 类,所以能充当锁的角色,通过 segment 段将 ConcurrentHashMap 划分为不同的部分,就可以使用不同的锁来控制对哈希表不同部分的修改,从而允许多个写操作并发进行,默认支持 16 个线程执行并发写操作,及任意数量线程的读操作。

    image-20230313113723907
  • put过程:

    1. 对key进行第1次hash,通过hash值确定Segment的位置
    2. 在Segment内进行操作,获取锁
    3. 获取当前Segment的HashEntry数组后对key进行第2次hash,通过hash值确定在HashEntry数组的索引位置(头部)
    4. 通过继承ReentrantLock的tryLock方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock方法去获取锁,超过指定次数就挂起,等待唤醒
    5. 然后对当前索引的HashEntry链进行遍历,如果有重复的key,则替换;如果没有重复的,则插入到链头
    6. 释放锁
  • get操作

    和put操作类似,也是要两次hash。但是get操作的ConcurrentHashMap不需要加锁,原因是存储元素都标记了volatile

  • size操作

    • size操作就是遍历两次所有的Segments,每次记录Segment的modCount值,然后将两次的modCount进行比较
    • 如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回。
      如果经判断发现两次统计出的modCount并不一致,要全部Segment加锁来进行count的获取和统计。在此期间每个Segement都被锁住,无法进行其他操作,统计出的count自然很准确。

8、ConcurrentHashMap 读操作为什么不需要加锁?

  • 在 HashEntry 类中,key,hash 和 next 域都被声明为 final 的,value 域被 volatile 所修饰,因此 HashEntry 对象几乎是不可变的,通过 HashEntry 对象的不变性来降低读操作对加锁的需求
    • next 域被声明为 final,意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改 next 引用值,因此所有的节点的修改只能从头部开始。但是对于 remove 操作,需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。
  • 用 volatile 变量协调读写线程间的内存可见性;
  • 若读时发生指令重排序现象(也就是读到 value 域的值为 null 的时候),则加锁重读;

9、怎么解决hash冲突

  • 链地址法:对于相同的哈希值,使用链表进行连接。(HashMap使用此法

    • 优点:

      1. 处理冲突简单,无堆积现象。即非同义词决不会发生冲突,因此平均查找长度较短。
      2. 适合总数经常变化的情况。(因为拉链法中各链表上的结点空间是动态申请的)
      3. 占空间小。装填因子可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计。
      4. 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
    • 缺点

      1. 查询时效率较低。(存储是动态的,查询时跳转需要更多的时间)
      2. 在key-value可以预知,以及没有后续增改操作时候,开放定址法性能优于链地址法。
      3. 不容易序列化。
  • 再哈希法:提供多个哈希函数,如果第一个哈希函数计算出来的key的哈希值冲突了,则使用第二个哈希函数计算key的哈希值。

    • 优点:不易产生聚集
    • 缺点:增加了计算时间
  • 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

  • 开放定址法:当关键字key的哈希地址p =H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,若p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。

三、Java 多线程

1、进程和线程的区别

  • 根本区别:进程是操作系统进行资源分配的最小单元,是操作系统对一个正在运行的程序的一种抽象,可以把进程看作程序运行的一次运行过程;线程是操作系统进行运算调度的最小单元
  • 进程中包含了线程,线程属于进程
  • 进程的创建、销毁和切换的开销都远大于线程
  • 每个进程有自己的内存和资源,一个进程中的线程会共享这些内存和资源
  • 子进程无法影响父进程,而子线程可以影响父线程,如果主线程发生异常会影响其所在进程和子线程
  • 进程的CPU利用率较低,因为上下文切换开销较大,而线程的CPU的利用率较高,上下文的切换速度快
  • 进程的操纵者一般是操作系统,线程的操纵者一般是编程人员

2、什么是上下文切换

  • 即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。 CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个 任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这 个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

3、并发和并行的区别

  • 并发:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行
  • 并行:当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行

4、线程有哪几种状态

  • **初始(NEW)**:新创建了一个线程对象,但还没有调用start()方法
  • **运行(RUNNABLE)**:Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
    线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)
  • **阻塞(BLOCKED)**:表示线程阻塞于锁
  • **等待(WAITING)**:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)
  • **超时等待(TIMED_WAITING)**:该状态不同于WAITING,它可以在指定的时间后自行返回
  • **终止(TERMINATED)**:表示该线程已经执行完毕

5、volatile 关键字的作用和原理

  • 禁止了指令重排(指令重排是指:为了提高性能,编译器和和处理器通常会对指令进行指令重排序)

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量值,这个新值对其他线程是立即可见的

    • 这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取
  • 不保证原子性

  • 原理:volatile的原理是在生成的汇编代码中多了一个lock前缀指令,这个前缀指令相当于一个内存屏障,这个内存屏障有3个作用:

    • 确保指令重排的时候不会把屏障后的指令排在屏障前,确保不会把屏障前的指令排在屏障后。
    • 将当前处理器缓存行的数据写回到系统内存
    • 一个处理器的缓存回写到内存会导致其他处理器的缓存失效

6、synchronized 关键字的作用

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法:也就是给当前类(即XXX.class对象)加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
  • synchronized 可重入性(当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功)

7、synchronized 和 ReentrantLock的区别

  • 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块
  • 获取锁和释放锁的机制不同:synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要手动加锁和释放锁
  • 锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁
  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断
  • 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的

8、synchronized 原理

  • Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。
  • synchronized 修饰的代码块:使用monitorentermonitorexit 指令来实现同步的。原理:
    • 我们通过解析synchronized修饰的代码块的字节码发现:同步语句块的实现使用的是monitorenter 和 monitorexit 指令。也就是在代码块的开始位置会添加一个monitorenter指令,在结束位置会添加一个monitorexit指令。
    • 当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor(对象监听器:每个对象都会对应一个monitor) 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。
    • 倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。
    • 值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
    • 总的来说,synchronized修饰代码块同步的底层原理就是在代码块的前后位置使用monitorenter和monitorexit 指令。monitorenter会判断是否可以获取当前对象锁的对象监听器,如果可以获取将就将其锁计数器加1。此时其他线程要获取该对象锁的对象监听器的时候就会被阻塞。直到当前线程执行monitorexit 指令,进行对象监听器的释放,同时将锁计数器减1。
  • synchronized 修饰的同步方法:同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。原理:
    • 方法级的同步是隐式的,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
    • 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
    • 在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

9、synchronized锁升级策略

前言: 我们刚刚所提到的synchronized是通过获取monitor(对象监听器)实现的,监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,这种实现方式导致synchronized属于重量级锁。意思就是这种锁比较消耗系统内核资源,所以Java 6之后Java官方对从JVM层面对synchronized进行了优化,也就是所说的synchronized锁升级策略。

  • 偏向锁:

    • 偏向锁它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
    • 偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
    • 所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
  • 轻量级锁:

    • 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
  • 自旋锁:

    • 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
  • 重量级锁:

    • 当自旋锁失败之后,就会升级成重量级锁了,也就是我们刚刚一直在讲述的synchronized的底层实现原理。
  • 消除锁:

    • 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
  • 锁升级策略概述:

    • 偏向锁:
      • 当锁对象第一次被线程获取的时候,线程使用CAS操作把这个锁的线程ID记录在对象头之中,同时置偏向标志位1。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。
      • 如果线程使用CAS操作时失败则表示该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁的所有权。当到达全局安全点(safepoint,这个时间点上没有正在执行的字节码)时获得偏向锁的线程被挂起,膨胀为轻量级锁。也就是说:当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束
    • 轻量级锁:
      • 当前线程使用CAS将对象头的mark Word锁标记位替换为锁记录指针,如果成功,当前线程获得锁。
      • 如果失败,表示其他线程竞争锁,当前线程尝试通过自旋获取锁
      • 如果自旋成功则依然处于轻量级状态
      • 如果自旋失败,升级为重量级锁
    • 重量级锁: 此时就升级到重量级锁了

10、ReentrantLock原理

  • CAS操作:

    • CAS是一种无锁算法。有3个操作数:内存值V、旧的预期值A、要修改的新值B。
    • 当我们要修改内存值的时候要进行判断,当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
  • AQS队列

    • AQS是一个用于构建锁和同步容器的框架。
    • AQS使用一个FIFO的队列(也叫CLH队列,是CLH锁的一种变形, 底层通过双向链表实现),表示排队等待锁的线程。队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。
  • ReentrantLock的锁流程

    • ReentrantLock先通过CAS尝试获取锁,如果获取了就将锁状态state设置为1
    • 如果此时锁已经被占用,
      • 被自己占用:判断当前的锁是否是自己占用了,如果是的话就锁计数器会state++(可重入性)
      • 被其他线程占用:该线程加入AQS队列并wait()
    • 当前驱线程的锁被释放,一直到state==0,挂在CLH队列为首的线程就会被notify(),然后继续CAS尝试获取锁,此时:
      • 非公平锁,如果有其他线程尝试lock(),有可能被其他刚好申请锁的线程抢占
      • 公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

11、公平锁与非公平锁

  • 如果在绝对时间上,先对锁进行获取的请求一定先被满足,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。那么这个锁是公平的,反之,是不公平的
  • 公平锁是严格的以FIFO的方式进行锁的竞争,但是非公平锁是无序的锁竞争,刚释放锁的线程很大程度上能比较快的获取到锁,队列中的线程只能等待,所以非公平锁可能会有“饥饿”的问题。但是重复的锁获取能减小线程之间的切换,而公平锁则是严格的线程切换,这样对操作系统的影响是比较大的,所以非公平锁的吞吐量是大于公平锁的,这也是为什么JDK将非公平锁作为默认的实现。
    ReentrantLock既可以是公平锁又可以是非公平锁,synchronized 是非公平锁

12、乐观锁和悲观锁

  • 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作
  • 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据

13、sleep 和 wait 的区别

  • sleep 方法是 Thread 类中的静态方法,wait 是Object类中的方法
  • sleep 并不会释放同步锁,而 wait 会释放同步锁
  • sleep 可以在任何地方使用,而 wait 只能在同步方法或同步代码块中使用
  • sleep 中必须传入时间,而 wait 可以传,也可以不传,不穿时间的话只有 notify 或者notifyAll 才能唤醒,传时间的话在时间之后才会唤醒

14、join、yield、interrupt、notify和notifyAll的作用

  • join:当前线程里调用其它线程thread的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程thread执行完毕或者millis时间到,当前线程进入就绪状态
  • yield:当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程
  • interrupt:当前线程里调用其它线程thread的interrupt()方法,中断指定的线程。如果指定线程调用了wait()方法或者join方法在阻塞状态,那么指定线程会抛出InterruptedException
  • notify:唤醒在此对象监视器(锁对象)上等待的单个线程,选择是任意性的
  • notifyAll:唤醒在此对象监视器(锁对象)上等待的所有线程

15、ThreadLocal的作用

ThreadLocal的作用是提供线程内的局部变量,说白了,就是在各线程内部创建一个变量的副本,相比于使用各种锁机制访问变量,ThreadLocal的思想就是用空间换时间,使各线程都能访问属于自己这一份的变量副本,变量值不互相干扰,减少同一个线程内的多个函数或者组件之间一些公共变量传递的复杂度

16、ThreadLocal的原理

每个Thread对象都有一个ThreadLocalMap,ThreadLoalMap中初始化了一个大小16的Entry数组,Entry对象用来保存每一个key-value键值对。当创建一个ThreadLocal的时候,就会将该ThreadLocal对象添加到该Map中,其中键就是ThreadLocal,值可以是任意类型,键值对以Entry类存储。

image-20230312174042823

17、什么是死锁?死锁产生的原因?

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止

原因:

  • 互斥条件:一个资源只能被一个线程占有,当这个资源被占有后其他线程就只能等待
  • 不可剥夺条件:当一个线程不主动释放资源时,此资源一直被拥有线程占有
  • 请求和保持条件:线程已经拥有了一个资源后,又尝试请求新的资源
  • 环路等待条件:产生死锁一定是发生了线程资源环形链

18、自己写代码时如何有效避免死锁

  • 设置超时时间:使用JUC包中的Lock接口提供的**tryLock**方法。该方法在获取锁的时候, 可以设置超时时间, 如果超过了这个时间还没拿到这把锁, 那么就可以做其他的事情, 而不是像synchronized如果没有拿到锁会一直等待下去
  • 多使用JUC包提供的并发类,而不是自己设计锁:JDK1.5后, 有JUC包提供并发类, 而不需要自己用wait 和notify来进行线程间的通信操作 , 这些成熟的并发类已经考虑的场景很完备了, 比自己设计锁更加安全。JUC中的并发类,例如 ConcurrentHashMap、ConcurrentLinkedQueue、AtomicBoolean 等等;实际应用中java.util.concurrent.atomic 包中提供的类使用广泛, 简单方便, 并且效率比Lock更高
  • 尽量降低锁的使用粒度:用不同的锁 ,而不是同一个锁。整个类如果使用一个锁来保护的话, 那么效率会很低, 而且有死锁的风险, 很多线程都来用这把锁的话, 就容易造成死锁。锁的使用范围, 只要能满足业务要求, 范围越小越好
  • 尽量使用同步代码块 而不是同步方法
    • 同步方法是把整个方法给加上锁给同步了,范围较大,造成性能低下,使用同步代码块范围小,性能高
    • 使用同步代码块, 可以自己指定锁的对象, 这样有了锁的控制权, 这样也能避免发生死锁
  • 给线程起有意义的名字
  • 避免锁的嵌套
  • 分配锁资源之前先看能不能收回来资源:银行家算法
  • 专锁专用

19、atomic 原理

Atomic包的类的实现绝大调用Unsafe的方法,而Unsafe底层实际上是调用C代码,C代码调用汇编,最后生成出一条CPU指令cmpxchg,完成操作(CAS)。这也就为啥CAS是原子性的,因为它是一条CPU指令,不会被打断。

20、线程池构造函数

  • corePoolSize:核心线程数

    • 线程池维护的最小线程数量,核心线程创建后不会被回收(注意:设置 allowCoreThreadTimeout = true 后,空闲的核心线程超过存活时间也会被回收)
    • 大于核心线程数的线程,在空闲时间超过keepAliveTime后会被回收
    • 线程池刚创建时,里面没有一个线程,当调用 execute() 方法添加一个任务时,如果正在运行的线程数量小于corePoolSize,则马上创建新线程并运行这个任务
  • maximumPoolSize:最大线程数

    • 线程池允许创建的最大线程数量。当添加一个任务时,核心线程数已满,工作队列已满的情况下,线程池还没达到最大线程数,并且没有空闲线程,创建一个新线程,然后从工作队列的头部取出一个任务交由新线程来处理,而将刚提交的任务放入工作队列尾部
  • keepAliveTime:空闲线程存活时间

    • 当一个可被回收的线程的空闲时间大于keepAliveTime,就会被回收
    • 可被回收的线程:
      1. 设置allowCoreThreadTimeout = true的核心线程
      2. 大于核心线程数的线程(非核心线程)
  • unit:时间单位

    • TimeUnit.NANOSECONDS // 纳秒
    • TimeUnit.MICROSECONDS // 微秒
    • TimeUnit.MILLISECONDS // 毫秒
    • TimeUnit.SECONDS // 秒
    • TimeUnit.MINUTES // 分钟
    • TimeUnit.HOURS // 小时
    • TimeUnit.DAYS

  • workQueue:工作队列

    • 作用:存放待执行任务的队列。当提交的任务数超过核心线程数大小后,再提交的任务就存放在工作队列,任务调度时再从队列中取出任务。它仅仅用来存放被 execute() 方法提交的 Runnable 任务。工作队列实现了 BlockingQueue 接口
    • JDK默认的工作队列有五种
      • ArrayBlockingQueue:数组型阻塞队列。数组结构,初始化时传入大小(有界),FIFO(先进先出策略)。使用一个重入锁(ReentrantLock),默认使用非公平锁,入队和出队共用一个锁,互斥
      • LinkedBlockingQueue:链表型阻塞队列。链表结构,默认初始化大小为Integer.MAX_VALUE,有界(近似无界),FIFO(先进先出策略)。使用两个重入锁分别控制元素的入队和出队,用 Condition 进行线程间的唤醒和等待
      • SynchronousQueue:同步移交队列。容量为0,添加任务必须等待取出任务,这个队列相当于通道,不存储元素
      • PriorityBlockingQueue:优先级阻塞队列。无界,在 put 的时候会tryGrow,要说它有界也没问题,因为界是 Integer.MAX_VALUE,但其实上这个队列应该是无界的。默认采用元素自然顺序升序排列(可以自定义Comparator)。使用一个重入锁分别控制元素的入队和出队
      • DelayQueue:延时队列。无界,队列中的元素有过期时间,过期的元素才能被取出。使用一个重入锁分别控制元素的入队和出队,用 Condition 进行线程间的唤醒和等待。任务调度时候可以使用
  • threadFactory:线程工厂

    • 创建线程的工厂,可以设定线程名、线程编号等
    • 默认创建的线程工厂,通过Executors.defaultThreadFactory()获取
  • handler:拒绝策略

    • 当线程池线程数已满,并且工作队列达到限制,新提交的任务使用拒绝策略处理。可以自定义拒绝策略,拒绝策略需要实现 RejectedExecutionHandler 接口
    • JDK默认的拒绝策有四种
      • AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常(默认拒绝策略)
      • DiscardPolicy:丢弃任务,但是不抛出异常。可能导致无法发现系统的异常状态
      • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
      • CallerRunsPolicy:由调用线程处理该任务

21、线程池原理

image-20230314093259155

四、JVM

1、JVM内存区域的划分

JVM 的内存区域可以分为两类:线程私有和区域和线程共有的区域。线程私有的区域:程序计数器、JVM 虚拟机栈、本地方法栈。线程共有的区域:堆、方法区、运行时常量池

  • 程序计数器 :每个线程有有一个私有的程序计数器,任何时间一个线程都只会有一个方法正在执行,也就是所谓的当前方法。程序计数器存放的就是这个当前方法的JVM指令地址

  • JVM虚拟机栈:创建线程的时候会创建线程内的虚拟机栈,栈中存放着一个个的栈帧,对应着一个个方法的调用。JVM 虚拟机栈有两种操作,分别是压栈和出栈。栈帧中存放着局部变量表、方法返回值和方法的正常或异常退出的定义等等

  • 本地方法栈:跟 JVM 虚拟机栈比较类似,只不过它支持的是 Native 方法

  • :堆是java内存中最大的一块,被所有线程共享,在虚拟机创建时创建,堆的唯一目的就是存放对象实例。
    java堆是垃圾收集器管理的主要区域,所以又被叫为GC堆,现在收集器基本都是采用分代收集算法,所以java堆很可以细分新生代和老年代;新生代再细致一点的有Eden空间、From Survivor空间、To Survivor空间

  • 方法区:方法区是所有线程共享内存区域,它主要是被用来储存java虚拟机加载过类的类信息、常量、静态变量即编译器编译后的代码数据

  • 常量池

    • 静态常量池(Class文件常量池)
      静态常量池主要是存放编译后的字面量和符号引用,字面量相当于java语言层面的常量概念,如文本字符串,声明为final的常量值,符号引用则属于编译原理的内容,主要为:类和接口的全限定名、字段名称和描述符、方法名称和描述符。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
    • 动态常量池(运行时常量池)
      动态常量池是jvm虚拟机在完成类装载的操作后将静态常量池的常量保存在方法区中,动态常量池对比静态常量池具有动态性,并非Class文件常量池中的常量才能进入运行时常量池,运行期间也可以将新的常量放入池中,这种特性主要在String类中的inter()运用。
    image-20230315212135170

2、JVM 哪些区域会发生 OOM

除了程序计数器,其他的部分都会发生 OOM

  • 堆内存,堆内存不足是最常见的发送OOM的原因之一。
    如果在堆中没有内存完成对象实例的分配,并且堆无法再扩展时,将抛出OutOfMemoryError异常,抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”
  • Java虚拟机栈和本地方法栈,这两个区域的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,在内存分配异常上是相同的。
    在JVM规范中,对Java虚拟机栈规定了两种异常:
    a. 如果线程请求的栈大于所分配的栈大小,则抛出StackOverFlowError错误,比如进行了一个不会停止的递归调用;
    b. 如果虚拟机栈是可以动态拓展的,拓展时无法申请到足够的内存,则抛出OutOfMemoryError错误
  • 除了程序计数器,其他的部分都会发生 OOM

3、GC(垃圾回收)机制

  • 内存标记算法:在内存被回收前,系统必须标记哪些内存已经没有人使用可以释放,这个工作就由内存标记算法的来完成,在Java各版本中,使用过如下几种标记算法。

    • 引用计数法(Reference Counting): 每个堆中分配的对象都有一个引用计数器,当一个对象被引用时,它的引用计数器会加一,垃圾回收时会清理掉引用计数为0的对象。但这种方法有一个问题,比方说有两个对象 A 和 B,A 引用了 B,B 又引用了 A,除此之外没有别的对象引用 A 和 B,那么 A 和 B 在我们看来已经是垃圾对象,需要被回收,但它们的引用计数不为 0,没有达到回收的条件。正因为这个循环引用的问题,Java 并没有采用引用计数法。
    • 可达性分析法(Reference Counting): 又称跟踪算法(Tracing),算法引入了图论,把所有对象间的关系看成一张图,内存标记从一组根节点(GC Root Set)开始,通过递归搜索,建立对象的引用关系图,当搜索完毕后,图外的对象就是可回收对象。这是目前Java中使用的内存标记算法。根级对象一般包括 Java 虚拟机栈中的对象本地方法栈中的对象方法区中的静态对象常量池中的常量
  • 内存回收方式:

    • 复制算法: 复制算法是将存活的对象复制到另一块内存区域中,并做相应的内存整理工作。复制算法的优点是可以避免内存碎片化,缺点也显而易见,它需要两倍的内存。

    • 标记-清除算法(Mark and Sweep)

      采用跟踪算法标记内存对象后,再扫描堆内存中未被标记的对象,进行回收。此算法不移动对象,仅对不存活对象进行回收,在存活对象占比高的情况下处理效率高,但不移动对象会引起内存碎片。

    • 标记-整理算法(Compacting)
      此方法和标记-清理算法使用相同标记算法,但在对不存活对象回收时,会把存活对象向内存前部空闲区域移动,同时更新对象的指针。此方法在清理的基础上,会对对象进行移动,执行成本较高,但可解决内存碎片问题。基于此算法的内存回收实现,一般会增加句柄和句柄表。

    • 分代回收策略
      JDK8中,堆中移除了永生代区域,堆内存主要由新生代和老年代两部分组成。其中新生代由一个伊甸园(Eden)和两个幸存者Survivor From和Survivor To 3部分组成,新创建对象首先保存在Eden中,当Eden中对象达到一定数量时,JVM触发Minor GC,GC时,先把Eden和From中的存活对象拷贝到Survivor To区,再清除Eden和From两个区域的数据,最后From和To互换身份,完成一次内存回收。新生代区域对象数量大,存活时间短,一般采用复制算法,通过这种结构和回收方式来提高垃圾回收效率,减少内存碎片。
      经过若干(默认15)次后还存活的对象,将进入老年代区,当老年代数据满时,会触发Major GC(又称Full GC),此时新生代、老年代、元区域、直接内存区域都会执行GC操作。(大对象会直接进入老年代区,例如图片、很长的字符串及数组)

4、Java 内存泄漏的原因

  • 静态集合类。如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。
  • 单例模式。和静态集合导致内存泄漏原因类似,因为单例的静态特性,它的生命周期和 JVM 的生命周期一样长,所以如果单例对象持有外部对象引用,那么这个外部对象也不会被回收,那么就会发生内存泄漏。要注意!!!!单例对象如果持有Context,那么很容易引发内存泄漏,此时需要注意传递给单例对象的Context最好是Application Context。
  • 未关闭的资源类:如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、Statement或ResultSet不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。
  • 变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。
  • 非静态内部类持有外部类的引用。如果有地方引用了这个非静态内部类,会导致外部类也被引用。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
  • 改变哈希值。当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。
  • 缓存泄漏。内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘,对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。
  • 监听器和回调。内存泄漏还有一个常见来源是监听器和其他回调,如果客户端在实现的API中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存他的弱引用,例如将他们保存成为WeakHashMap中的键。

5、Java 对象模型

在Hotspot虚拟机中,对象在内存中的存储布局分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

image-20230312165224851
  • 对象头,又包括三部分:Mark WordKlass Word(元数据指针)、数组长度
    • Mark Word:用于存储对象运行时的数据,比如HashCode、锁状态标志、GC分代年龄等。这部分在64位操作系统下,占8字节(64bit),在32位操作系统下,占4字节(32bit)。
    • Klass Word:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。这部分就涉及到一个指针压缩的概念,在开启指针压缩的情况下,占4字节(32bit),未开启情况下,占8字节(64bit),现在JVM在1.6之后,在64位操作系统下都是默认开启的。
    • 数组长度:这部分只有是数组对象才有,如果是非数组对象,就没这部分了,这部分占4字节(32bit)。

6、一个空对象占用多少字节

只要是Java对象,那么就肯定会包括对象头,也就是说这部分内存占用是避免不了的。所以,在64位虚拟机,Jdk1.8(开启了指针压缩)的环境下,任何一个对象,啥也不做,只要声明一个类,那么它的内存占用就至少是96bits,也就是至少12字节,加上内存对齐填充的6字节,总共占用16字节。而不开启指针压缩情况下,则占用16字节。在32位虚拟机,一个空对象占用8字节(对象头)。

7、什么是内存对齐

CPU 并不会以一个一个字节去读取和写入内存。相反,CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度。假设一个32位平台的 CPU,那它就会以4字节为粒度去读取内存块,64位平台的 CPU则会以8字节为粒度去读取内存块。那为什么需要内存对齐呢?主要有两个原因:

  • 平台(移植性)原因:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况。
  • 性能原因:若访问未对齐的内存,将会导致 CPU 进行两次内存访问,并且要花费额外的时钟周期来处理对齐及运算。而本身就对齐的内存仅需要一次访问就可以完成读取动作。

8、什么是指针压缩(64位才支持)

当我们启用了-XX:+UseCompressedOops之后,我们原本的OOP(Ordinary Object Pointer,普通对象指针)就会被压缩,当然也不是所有的对象都会被压缩,只有 以下几种的对象才会被压缩:

  • 对象的全局静态变量(类属性)
  • 对象头中Klass Word信息
  • 对象的引用类型
  • 对象数组类型

而以下几种对象则不能被压缩:

  • 指向PermGen的Class对象指针
  • 局部变量
  • 传参
  • 返回值
  • NULL指针

指针压缩的大概原理: 通过对齐,还有偏移量将64位指针压缩成32位。零基压缩是针对压缩解压动作的进一步优化。 它通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配。