flyEn'blog

java多线程+JVM

进程和线程

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。

系统运行一个程序即是一个进程从创建、运行到消亡的过程。

在Java中,当我们启动main函数时其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称为主线程。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

Java程序天生就是多线程程序,我们可以通过JMX来看一下一个普通的Java程序有哪些线程,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class MultiThread {
public static void main(String[] agrs) {
// 获取java线程管理MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的 monitor 和synchronize 信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程ID和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("["+threadInfo.getThreadId()+"]"+threadInfo.getThreadName());
}
}
}

上述程序输出如下(输出内容可能不同):

1
2
3
4
5
[5]Monitor Ctrl-Break //添加事件
[4]Signal Dispatcher // 分发处理给JVM信号的进程
[3]Finalizer // 调用对象finalize方法的线程
[2]Reference Handler // 清除reference线程
[1]main //main线程,程序入口

从上面的输出内容可以看出:一个Java程序的运行是 main 线程和多个其他线程同时运行。

图解进程和线程的关系(JVM角度)

image-20200629115010220

从上图可以看出:一个进程可以有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

总结:线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程都是独立的,而各线程则不一定,因为同一进程中的线程极有可能会互相影响。线程执行开销小,但不利于资源的管理和保护;而进程相反。

程序计数器的作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器为什么私有:程序计数器私有主要是为了程序切换后能恢复到正确的执行位置

虚拟机栈

每个Java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在HotSpot虚拟机中和Java虚拟机合二为一。

虚拟机栈和本地方法栈为什么私有:为了保证线程中的局部变量不被别的线程访问到

堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象(所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发:同一时间段,多个任务都在执行。

并行:单位时间内,多个任务同时执行。

使用多线程可能带来的问题:

并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发变成并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题

线程的生命周期和状态

Java线程在运行的生命周期中的指定时刻只可能处于下面6种状态的其中一个状态。(图源自《Java并发编程艺术》)

image-20200629140859334

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java线程状态变迁如下图所示(图源自《Java并发编程艺术》)

image-20200629141205782

由上图可看出:线程创建之后它将处于NEW(新建)状态,调用 start()方法后 开始运行,线程这时候处于 READY(可运行)状态。可运行状态的线程获得了CPU时间片后就处于 RUNNING(运行)状态。

当线程执行 wait()方法之后,线程进入WAITING(等待)状态。进入等待状态的线程需要依靠其他线程的通知才能返回运行状态,而 TIME_WAITING(超时等待)状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将Java线程置于 TIMED WAITING状态。当超时时间到达后Java线程将会返回RUNNABLE状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到BLOCKED(阻塞)状态。线程在执行 Runnable的run()方法之后将会进入到TERMINATED状态。

上下文切换

多线程编程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换

概况来说就是:当前任务在执行完CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

线程死锁

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

image-20200629142648384

比如上图,线程A持有资源2,线程B持有资源1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

下面代码模拟了上图的死锁的情况(源于《并发编程之美)):

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
27
28
29
30
31
32
33
34
35
36
37
 
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "getresource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "getresource2");
}
}
}, "线程 1").start();

new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "getresource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting getresource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "getresource1");
}
}
}, "线程 2").start();
}
}

上述代码输出

1
2
3
4
5
 
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000);线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件

产生死锁的四个必要条件:互斥条件、请求与保持条件、不剥夺条件、循环等待条件。

sleep()和wait()

  • 两者最主要的区别在于:sleep方法没有释放锁,而wait方法释放了锁

  • 两者都可以暂停线程的执行。

  • wait通常被用于线程间交互/通信,sleep通常被用于暂停执行。
  • wait方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。

start()和run()

我们知道调用start()方法时会执行run()方法,为什么要调用start()而不能直接调用run()方法?

new一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程使线程进入了就绪状态,当分配到时间片后就可以开始运行了start()执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通 方法调用,还是在主线程里执行。

synchronized关键字 🌟

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

synchronized关键字最主要的三种使用方式:

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一 个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有 一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。

尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!

双重校验锁实现对象单例(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueInstance;

private Singleton(){}

public static Singleton getUniqueInstance() {
if(uniqueInstance == null){
// 类对象加锁
synchronized(Singleton.class){
if(uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

另外,uniqueInstance 采用 volatile 关键字修饰也是很有必要的。

uniqueInstance = new Singleton() 这段代码其实是分为三步执行:

  1. 为uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将uniqueInstance 指向分配的内存地址

但是由于JVM 具有指令重排的特性,执行顺序有可能变成 1-3-2。指令重排在单线程环境下不会出现问题,但是多线程环境下会导致一个线程获得还没有初始化的实例。

例如,线程T1执行了1和3,此时T2调用getUniqueInstance()后发现uniqueInstance不为空,因此返回uniqueInstance,但此时uniqueInstance还未被初始化。

使用 volatile 可以禁止JVM的指令重排,保证在多线程环境下也能正常运行。

synchronized关键字的底层原理

待学习补充。

volatile关键字

待学习补充。

Fork me on GitHub