Java 多线程详解
基本概念
线程和进程
线程和进程分别是什么? 常见的一个概念是
进程是操作系统资源分配的基本单位,而线程是CPU的基本调度单位。
但是显然这并不方便理解。 要解决这个问题可能需要从操作系统开始说起:
程序
计算机程序是一组计算机能识别和执行的指令
也就是我们写的代码或者编译出来的代码
进程
当程序运行起来就成为了一个进程,操作系统为了避免进程操作到为了避免进程A写入进程B的情况发生,会使用一个叫进程隔离的技术,进程间的通信可以使用进程间通信 (Inter-Process Communication,简称 IPC)完成。
进程隔离的实现,使用了虚拟空间地址(虚拟内存空间),进程A的虚拟地址和进程B的虚拟地址不同,这样就防止进程A将数据信息写入进程B。
就像井底之蛙以为自己看到的天空就是全部的天空一样,每个进程都以为自己独占了所有内存,但其实只是内存中的一部分。
程序并行
由于 CPU 的执行速度是远非常快的,配合时间片轮转调度
,我们可以认为 CPU 是可以同时处理很多程序运行。再加上现在 CPU 也都有很多核心,也就是说如果我们的程序想要运行得更快,就可以让尽可能多个代码片段一起并行运行。
线程
前面我们说 当程序运行起来就成为了一个进程
,因为并行的程序需要做同一件事情,那么有没有一种可以相互不隔离的“进程”呢?
这种相互不管理的“进程”就叫做线程,线程们共享进程的资源,同时又可以分别处理各自的程序片段。
井底之蛙
如果说进程是只有自己一片天的井底之蛙,那线程就是进程井里的一只青蛙了。
Linux 底层
创建进程和线程最终都调用了一个名为 do_dork()
的方法,只是一些关于资源(内存,文件等)的参数不同。
宏观看看
对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。
有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。
创建一个线程
Thread 类
public class MyThreadTest {
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
thread1.start();
}
}
class Thread1 extends Thread {
@Override
public void run() {
System.out.println("Thread1");
}
}
Java 中新建一个线程的方法就是继承 Thread
类然后重写 run()
方法。
Runnable 接口
可以看到Thread
类实现了Runnable
接口
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
当实现接口 Runnable 的对象用于创建线程时,启动线程会导致在单独执行的线程中调用对象的 run 方法。
函数式接口
Runnable
接口又是一个 函数式接口(@FunctionalInterface)
函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。
函数式接口可以被隐式转换为 lambda 表达式。
[ 挖一个函数式接口的坑 ]
Thread.start()
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* 什么也不做。如果start0抛出了一个Throwable,
那么它将向上传递到调用堆栈 */
}
}
}
private native void start0();
Java 本身并不能创建线程,所以 star()
方法中调用了原生方法 start0()
来创建线程。
线程的状态
在Java程序中,一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:
New:新创建的线程,尚未执行;
Runnable:运行中的线程,正在执行
run()
方法的Java代码;Blocked:运行中的线程,因为某些操作被阻塞而挂起;
Waiting:运行中的线程,因为某些操作在等待中;
Timed Waiting:运行中的线程,因为执行
sleep()
方法正在计时等待;Terminated:线程已终止,因为
run()
方法执行完毕。
用一个状态转移图表示如下:
┌─────────────┐
│ New │
└─────────────┘
│
▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
││ Runnable │ │ Blocked ││
└─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
│ Waiting │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│
▼
┌─────────────┐
│ Terminated │
└─────────────┘
线程同步(锁)
引子
让我们写一个 100 个线程自增的程序
public class LockP {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
for(int i = 0; i < 100; i++){
new Thread(()->{
for(int j = 0; j < 100; j++)
num++;
}).start();
}
sleep(1000); // 等待所有线程执行完毕
System.out.println(num);
}
}
执行三次结果分别是:
9862
9946
9936
可以看到程序并没有如我们所愿将num
自增到10000
,这是因为如果多个线程同时读写共享变量,会出现数据不一致的问题。
实际上++
操作并不是一个原子性的操作,所以其他线程有可能在某个线程++
的同时操作变量。
原子性:指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
传统方式
解决线程同步的问题就需要线程同步了,传统方式我们使用synchronized
关键字。
public class LockP {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
for(int i = 0; i < 100; i++){
new Thread(()->{
for(int j = 0; j < 100; j++)
synchronized (LockP.class) {
num++;
}
}).start();
}
sleep(1000); // 等待所有线程执行完毕
System.out.println(num);
}
}
它表示用LockP.class
作为一把锁,在执行synchronized (LockP.class) { .... }
代码块的时候只能有一个线程操作。
JUC
什么是JUC
新建线程的方法
生产者消费者
传统
虚假唤醒
JUC
集合安全
List
常用辅助类
CountDownLatch
CyclicBarrier
Semaphore
读写锁
队列
4组API
线程池
3大方法
7大参数
四种拒绝策略
JMM
Volatile
保证可见性
不保证原子性
禁止指令重排
CAS