JVM的内存分配

Java 虚拟机在执行 Java 程序的过程中,会把它所管理的内存划分为不同的数据区域。

  • java文件首先需要经过编译器编译,生成class字节码文件;
  • Java程序中访问这个类时,需要通过ClassLoader(类加载器)将class文件加载到JVM的内存中。
  • JVM中的内存可以划分为若干个不同的数据区域,主要分为:虚拟机栈、本地方法栈、堆、方法区、程序计数器

JVM的内存分配图

虚拟机栈

虚拟机栈是线程私有的,与线程的生命周期同步。

虚拟机栈的初衷是用来描述Java方法执行的内存模型,每个方法被执行的时候,JVM都会在虚拟机栈中创建一个栈帧。

一个线程包含多个栈帧,而每个栈帧内部包含局部变量表、操作数栈、动态连接、返回地址等。

  • 局部变量表:是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。
  • 操作数栈:(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈。
  • 动态连接:主要目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。在一个 class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。
  • 返回地址:作用是无论当前方法采用何种方式退出(正常退出/异常退出),在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

本地方法栈

本地方法栈和上面介绍的虚拟栈基本相同,只不过是针对本地(native)方法。在开发中如果涉及 JNI 可能接触本地方法栈多一些,在有些虚拟机的实现中已经将两个合二为一了(比如HotSpot)。

Java 堆(Heap)是 JVM 所管理的内存中最大的一块,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是 Java 垃圾收集器(GC)管理的主要区域,有时候也叫作”GC 堆”。
同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

按照对象存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为 Eden 和 Survivor 区。不同的区域存放具有不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,进而提高垃圾回收效率。

方法区

方法区(Method Area)也是 JVM 规范里规定的一块运行时数据区。方法区主要是存储已经被JVM 加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。

程序计数器

程序计数器是虚拟机中一块较小的内存空间,主要用于记录当前线程执行的位置

Java 程序是多线程的,CPU 可以在多个线程中分配执行时间片段。当某一个线程被 CPU 挂起时,需要记录代码已经执行到的位置,方便 CPU 重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用(分支操作、循环操作、跳转、异常处理)。

  • 在Java虚拟机规范中,对程序计数器这一区域没有规定任何OutOfMemoryError情况。
  • 它是线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • 当一个线程正在执行一个Java方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

GC垃圾回收机制

垃圾回收指的是JVM回收内存中已经没有用的对象(垃圾)。

不同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收:

  • Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。
  • System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC。

如何识别垃圾

JVM通过可达性分析算法来标识垃圾,首先通过GC Root作为起始点,然后向下进行搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。

可以作为GC Root的对象:

  1. Java 虚拟机栈(局部变量表)中引用的对象。
  2. 方法区中静态引用指向的对象。
  3. 仍处于存活状态中的线程对象。
  4. Native 方法中 JNI 引用的对象。

如何回收垃圾

通过垃圾回收算法:

  1. 标记清除算法(Mark and Sweep GC) 从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收。
  2. 标记-压缩算法 (Mark-Compact) 需要先从根节点开始对所有可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。
  3. 复制算法(Copying) 将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

JVM分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是 JVM 的内存分代策略。
注意: 在HotSpot中除了新生代和老年代,还有永久代

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

ClassLoader加载机制

在 Java 程序启动的时候,并不会一次性加载程序中所有的 .class 文件,而是在程序的运行过程中,动态地加载相应的类到内存中。

通常情况下,Java程序中的class文件会在以下2种情况下被ClassLoader主动加载到内存中:

  1. 调用类构造器
  2. 调用类中的静态(static)变量或者静态方法

JVM 中自带 3 个类加载器:

  1. 启动类加载器:BootstrapClassLoader
  2. 扩展类加载器:ExtClassLoader(JDK 1.9 之后,改名为 PlatformClassLoader
  3. 系统加载器:APPClassLoader

双亲委派模式

所谓双亲委派模式就是,当类加载器收到加载类或资源的请求时,通常都是先委托给父类加载器加载,也就是说,只有当父类加载器找不到指定类或资源时,自身才会执行实际的类加载过程。

这么设计的原因是为了防止危险代码的植入,比如String类,如果在AppClassLoader就直接被加载,就相当于会被篡改了,所以都要经过老大,也就是BootstrapClassLoader进行检查,已经加载过的类就不需要再去加载了。

举例:JVM加载HelloWorld.class,默认情况下,JVM首先使用AppClassLoader去加载HelloWorld.Class类。

  1. AppClassLoader 将加载的任务委派给它的父类加载器(parent)— ExtClassLoader。
  2. ExtClassLoader 的 parent 为 null,所以直接将加载任务委派给 BootstrapClassLoader。
  3. BootstrapClassLoader 在 jdk/lib 目录下无法找到 HelloWorld.Class 类,因此返回的 Class 为 null。
  4. 因为 parent 和 BootstrapClassLoader 都没有成功加载 HelloWorld.Class 类,所以AppClassLoader会调用自身的 findClass 方法来加载 HelloWorld.Class。

Class类的加载过程

class文件被加载到内存中所经过的详细过程,主要分 3 大步:装载、链接、初始化。其中链接中又包含验证、准备、解析 3 小步。

装载

查找字节流,并根据此字节流创建类的过程。装载过程成功的标志就是在方法区中成功创建了类所对应的 Class 对象。

链接

验证创建的类,并将其解析到 JVM 中使之能够被 JVM 执行。

  • 验证:文件格式检验、元数据检验、字节码检验、符号引用检验。
  • 准备:为类中的静态变量分配内存,并为其设置“0值”。基本类型的默认值为 0;引用类型默认值是 null。
  • 解析:把常量池中的符号引用转换为直接引用,也就是具体的内存地址。在这一阶段,JVM 会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。

初始化

将标记为 static 的字段进行赋值,并且执行 static 标记的代码语句。没有 static 修饰的语句块在实例化对象的时候才会执行。

线程、多线程、线程池

线程

线程就是进程中运行的多个子任务,是操作系统调用的最小单元。

线程状态:初始、运行、阻塞、等待、超时等待、终止。

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

多线程

在一个应用程序中执行多个线程操作,同步完成多项任务就叫做多线程。多线程是为了提高资源使用效率。

多线程实现方式:

  1. Thread、Handler配合使用
  2. AsyncTask、HandlerThread、IntentService
  3. 线程池ThreadPool

多线程三个特性

  1. 原子性:是指一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
    比如,对于一个静态全局变量int i,两个线程同时对它赋值,线程A给他赋值为1,线程B给他赋值为-1。那么不管这两个线程以何种方式,何种步调工作,i的值要么是1,要么是-1,线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不可被中断。
  2. 可见性:是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行来说,可见性问题是不存在的。
  3. 有序性:在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。

常见的四类功能线程池

  1. 定长线程池(FixedThreadPool)
  2. 定时线程池(ScheduledThreadPool)
  3. 可缓存线程池(CachedThreadPool)
  4. 单线程化线程池(SingleThreadExecutor)

常见四类线程池

线程池的作用和优点

线程池的主要作用是用于管理子线程,优点有:

  1. 复用线程池中的线程,避免频繁创建和销毁线程所带来的内存开销。
  2. 有效控制线程的最大并发数,避免因线程之间抢占资源而导致的阻塞现象。
  3. 能够对线程进行简单的管理,提供定时执行以及指定时间间隔循环执行等功能。

锁、死锁

锁的分类:

  • 公平锁/非公平锁(公平锁是指多个线程按照申请锁的顺序来获取锁)
  • 可重入锁(又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁)
  • 独享锁/共享锁(独享锁是指该锁一次只能被一个线程所持有)
  • 互斥锁/读写锁(ReentrantLock/ReadWriteLock)
  • 乐观锁/悲观锁(悲观锁在Java中的使用,就是利用各种锁。乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。)
  • 分段锁(分段锁其实是一种锁的设计,并不是具体的一种锁)
  • 偏向锁/轻量级锁/重量级锁(这三种锁是指锁的状态,并且是针对Synchronized)
  • 自旋锁(自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU)

synchronized

synchronized提供了同步锁的概念,被synchronized修饰的代码段可以防止被多个线程同时执行,必须一个线程把synchronized修饰的代码段都执行完毕了,其他的线程才能开始执行这段代码。
因为synchronized保证了在同一时刻,只能有一个线程执行同步代码块,所以执行同步代码块的时候相当于是单线程操作了,那么线程的可见性、原子性、有序性(线程之间的执行顺序)它都能保证了。

synchronized可以用来修饰以下3个层面:修饰实例方法;修饰静态类方法;修饰代码块。

  1. 修饰实例方法:锁是当前对象,只有同一个实例对象调用方法才会产生互斥效果,不同实例对象之间不会产生互斥效果。
  2. 修饰静态类方法:锁是当前类的Class对象,即使在不同线程中调用不同实例对象,也会有互斥效果。
  3. 修饰代码块:synchronized 作用于代码块时,锁对象就是跟在后面括号中的对象。任何Object对象都可以当作锁对象。

synchronized的缺点

  1. 无法判断获取锁的状态。
  2. 当持有锁的方法执行时间过长时就会一直占着锁不释放,导致其他使用同一锁的方法无法执行,必须等待,导致速度,效率减小。
  3. 当多个线程尝试获取锁的时候,未获取到锁的线程会不断尝试去获取锁而不会发生中断,这样会造成性能消耗。
  4. 有可能产生死锁,导致程序中断。

synchronized的优化

  1. 锁膨胀(锁升级)
    锁膨胀是指 synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫锁膨胀也叫锁升级。
    JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。

  2. 锁消除
    锁消除指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
    锁消除的依据是逃逸分析的数据支持,如 StringBuffer 的 append() 方法,或 Vector 的 add() 方法,在很多情况下是可以进行锁消除的。
    比如在一个方法里面定义了一个局部变量StringBuffer,因为这个局部变量不会从该方法中逃逸出去,此时我们可以使用锁消除(不加锁)来加速程序的运行。

  3. 锁粗化
    锁粗化是指,将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
    锁粗化的作用:如果检测到同一个对象执行了连续的加锁和解锁的操作,则会将这一系列操作合并成一个更大的锁,从而提升程序的执行效率。

  4. 自适应自旋锁
    自旋锁是指通过自身循环,尝试获取锁的一种方式。
    自旋锁优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。
    如果长时间自旋还获取不到锁,那么也会造成一定的资源浪费,所以我们通常会给自旋设置一个固定的值来避免一直自旋的性能开销。

    自适应自旋锁是指:线程自旋的次数不再是固定的值,而是一个动态改变的值,这个值会根据前一次自旋获取锁的状态来决定此次自旋的次数。 如果线程自旋成功了,则下次自旋的次数会增多,如果失败,下次自旋的次数会减少。

其中锁膨胀和自适应自旋锁是 synchronized 关键字自身的优化实现,而锁消除和锁粗化是 JVM 虚拟机对 synchronized 提供的优化方案。

volatile

其实volatile关键字的作用就是保证了可见性和有序性(不保证原子性),如果一个共享变量被volatile关键字修饰,那么如果一个线程修改了这个共享变量后,其他线程是立马可知的。

volatile能禁止指令重新排序,在指令重排序优化时,在volatile变量之前的指令不能在volatile之后执行,在volatile之后的指令也不能在volatile之前执行,所以它保证了有序性。

ReentrantLock

ReentrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。

ReentrantLock 的使用同 synchronized 有点不同,它的加锁和解锁操作都需要手动完成。lock() 和 unlock()

默认情况下,synchronized 和 ReentrantLock 都是非公平锁。
但是 ReentrantLock 可以通过传入 true 来创建一个公平锁。所谓公平锁就是通过同步队列来实现多个线程按照申请锁的顺序获取锁。

死锁

死锁发生的四个必要条件

  1. 资源互斥使用。
  2. 多个进程(线程)保持一定的资源,但又请求新的资源。
  3. 资源不可被剥夺。
  4. 多个进程循环等待。

一般死锁的应对策略有

  1. 死锁预防:如进程需要的所有资源,在一开始就全部申请好得到之后再开始执行。
  2. 死锁避免:如进程每次申请申请资源的时候,根据一定的算法,去看该请求可能不可能造成死锁,如果可能,就不给它分配该资源。
  3. 死锁处理:破坏四个必要条件的其中一个,比如kill掉一个进程。
  4. 死锁忽略:不管死锁,由用户自行处理,比如重启电脑。一般的系统其实都采取这种策略。

常见面试题锦集

Class类的加载执行顺序(包含静态变量和方法)

静态变量/静态代码块 -> 普通代码块 -> 构造函数

  1. 父类静态变量和静态代码块;
  2. 子类静态变量和静态代码块;
  3. 父类普通成员变量和普通代码块;
  4. 父类的构造函数;
  5. 子类普通成员变量和普通代码块;
  6. 子类的构造函数。

如何停止正在运行的线程

  1. 使用thread.interrupt()方法停止线程,可使用isInterrupted()方法配合return终止向下执行。
  2. 使用退出标志,使线程正常退出,也就是run()方法执行完后终止。
  3. 使用抛异常的方式终止执行,上层可以通过try-catch捕获异常。
  4. 使用stop()方法强行终止,但是不推荐,因为不安全已经废弃。

线程池ThreadPool的构造函数

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
// 1. 创建线程池
// 创建时,通过配置线程池的参数,从而实现自己所需的线程池
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
Executor threadPool = new ThreadPoolExecutor(...);
// 2. 向线程池提交任务:execute
threadPool.execute(new Runnable() {
@Override
public void run() {
... // 线程执行任务
}
});
threadPool.shutdown(); // 3. 关闭线程池shutdown()
// 关闭线程的原理
// a. 遍历线程池中的所有工作线程
// b. 逐个调用线程的interrupt()中断线程(注:无法响应中断的任务可能永远无法终止)
// 也可调用shutdownNow()关闭线程:threadPool.shutdownNow()
// 二者区别:
// shutdown:设置 线程池的状态 为 SHUTDOWN,然后中断所有没有正在执行任务的线程
// shutdownNow:设置 线程池的状态 为 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表
// 使用建议:一般调用shutdown()关闭线程池;若任务不一定要执行完,则调用shutdownNow()

构造参数:

  1. corePoolSize: 线程池的核心线程数,说白了就是,即便是线程池里没有任何任务,也会有corePoolSize个线程在候着等任务。
  2. maximumPoolSize: 最大线程数,不管你提交多少任务,线程池里最多工作线程数就是maximumPoolSize。
  3. keepAliveTime: 线程的存活时间。当线程池里的线程数大于corePoolSize时,如果等了keepAliveTime时长还没有任务可执行,则线程退出。
  4. unit: 这个用来指定keepAliveTime的单位,比如秒:TimeUnit.SECONDS。
  5. workQueue: 一个阻塞队列,提交的任务将会被放到这个队列里。
  6. threadFactory: 线程工厂,用来创建线程,主要是为了给线程起名字,默认工厂的线程名字:pool-1-thread-3。
  7. handler: 拒绝策略,当线程池里线程被耗尽,且队列也满了的时候会调用。

进程和线程的区别

  1. 地址空间:进程之间是独立的地址空间,同一进程里的线程共享本进程的地址空间。
  2. 资源拥有:进程之间的资源是独立的,同一进程里的线程共享本进程的资源(如内存,I/O,CPU,用户存储)。
  3. 健壮性:一个进程崩溃后不会对其他进程产生影响,但是一个线程崩溃会导致整个进程死掉,所以多进程比多线程健壮。
  4. 资源占用:进程间切换时,消耗的资源大,效率不高,所以当要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程。
  5. 执行过程:每个独立的进程有一个程序运行的入口,顺序执行序列。但是线程不能独立执行,必须依存于应用程序中。
  6. 线程是处理器调度的基本单位,进程不是。
  7. 两者都可以并发执行。

volatile的作用,能否保证线程安全

volatile只能作用于变量,能够禁止指令重排,保证可见性和有序性。
因为不能保证原子性,所以volatile不能保证线程安全。

synchronized和volatile的区别

  1. volatile只能作用于变量,使用范围较小,synchronized可以用在方法、类、同步代码块等,使用范围比较广。
  2. volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。
  3. volatile不会造成线程阻塞,synchronized可能会造成线程阻塞。

synchronized和ReentrantLock的区别

  1. ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
  2. ReentrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
  3. ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。