该系列分暂为3部,我会在这里使用大白话讲述学到的多线程知识。
第一部讲述多线程的基础概念、线程的创建、使用等一些基础概念
第二部讲述并发编程、一些线程工具类的使用、线程池技术。
第三部讲述并发编程在项目中的应用,举一些项目中的实际例子。
第一章:重生
在学习Thread之前,我们先简单回顾一下并发与并行的概念。
我们知道我们写的代码在cpu中是顺序执行的,也就是说一个cpu在一个时间维度上只能执行一行代码,一个cpu不可能同时执行两个线程的两行代码。并发:but,cpu的的执行速度非常快,让一个单核cpu去将时间片分给多个线程去执行,通过cpu调度器快速切换时间片,快速地在多个线程的代码中来回切换地执行代码,给人一种多个线程同时执行的错觉。 并行:多核cpu,每个cpu负责一个线程,是真正意义上的同时进行。
这里再多插一句同步与异步的概念,这两个词更多出现在前端开发中,你一定听说过前端异步发起请求,这是为了不阻塞ui界面,提升用户体验。在java多线程中,同步异步的概念也是如此。同步:代码顺序执行,必须执行完上一行代码才可继续往下执行。异步:java的异步是通过线程实现的。例如:磁盘读写,为了不避免主线程阻塞等待磁盘读写结束,将这个类耗时的操作单独开一个线程去处理,代码继续往下执行。
在这里需要纠正一些错误的观念,这是很多初学者容易犯的错误:
1:在单核cpu下多线程并发并不能简单的提升效率,反而会降低效率,这是因为并发即使给你的感觉是同时在跑多个任务,但这只是错觉,单核cpu在并发时在不同的线程之间切换,而线程的切换,线程上下文的不断切换是需要消耗cpu资源的。只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活
2:多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的。这里后面再详细讲
-
有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分【阿姆达尔定律】
-
也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
第二章:Java 线程
在JVM的内存中存在这么几块内存区,堆、栈、方法区。我们平时创建的对象全都保存在堆内存中,将对象的地址保存在栈中,这个栈内存就是给线程使用的。我们每创建一个线程,JVM都会为这个线程分配一个单独的栈内存,不同的线程之间是无法直接访问栈内存的(甚至彼此都不知道对方的存在)。
栈内存是由一个个栈帧组成的,线程每执行一个方法就会将该方法放入栈帧并压到栈顶,方法一执行结束就会弹出该栈帧,直到所有方法都弹出栈,在栈底的main方法都弹出栈了,这个线程也就执行结束了。
2.1 创建和运行线程
- 方式一:直接使用Thread
Thread t= new Thread("name"){
@override
public void run(){
log.info("业务代码");
}
}
t.start();
- 方式二:使用Runnable,把任务与线程分开
// 创建任务对象
Runnable task2 = new Runnable() {
@Override
public void run() {
log.debug("hello");
}
};
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();
//直接使用lambda表达式
Thread t1 = new Thread(()->{log.debug("hello")},"name");
t1.start();
- 方式三:使用FutureTask,可以拿到线程的执行结果
// FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况
// 创建任务对象
FutureTask task3 = new FutureTask<Integer>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
log.info("run .....");
return 100;
}
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);
//Callable 也是一个接口,里面有一个call方法,并且该方法可以抛出异常
插一个工作常用小知识:windows查看端口占用,并占该端口的进程
1:netstat -aon | findstr “端口号” 先找到占用的进程信息
2:tasklist | findstr “PID” 通过上一步查到进程id进一步查看具体是什么进程
3:taskkill /t /f /pid 杀死进程
linux下查找进程
1:使用ps命令
ps -ef | grep java 查出java所有的进程信息
kill -9 pid 杀死pid进程
2:使用jdk自带的命令专门用来查java进程
jps 该命令会直接查出所有运行的java进程
3:使用top
top:该命令可以动态查看进程信息,包括进程cpu使用情况
top -H -p pid : 该命令可以动态查看进程中的所有线程信息
4:使用jdk自带的jstack
jstack pid :查看这一刻某个进程中线程的信息,更为详细。线程是否在阻塞等
2.2 Thread类中的方法
1:start 和 run
start方法:实例方法。启动一个新线 程,在新的线程 运行 run 方法 中的代码start 方法只是让线程进入就绪,里面代码不一定立刻 运行(CPU 的时间片还没分给它)。每个线程对象的 start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException
run方法:实例方法。新线程启动后会调用该方法,如果在构造 Thread 对象时传递了 Runnable 参数,则 线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象, 来覆盖默认行为
调用start方法才会开启线程,调用手动调用run方法只是单纯执行run方法中的代码,不加sleep,线程会占满单核cpu资源,仅仅休眠1毫秒,cpu占用也会降低到百分之3左右。这是不需要加锁的情况后续还会使用wait等其他方式来达到类似效果。
Thread t= new Thread("name"){
@override
public void run(){
while(true){
Thread.sleep(1);
log.info("业务代码");
}
}
}
t.start();
2:join
join():实例方法。该方法会阻塞等待线程执行结束,在主线程中调用 t1.join() 表示等待t1线程执行结束再继续往下执行
join(Long n):实例方法。该方法接收一个毫秒参数,表示最多等n毫秒,无论线程是否执行完都往下继续执行
3:sleep 和 yield
sleep(Long n):静态方法。让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程
在线程中如需使用while(true)时,我们可以使用sleep方法来防止cpu空转,占用cpu过高。在单核cpu中不加sleep方法cpu占用会高达百分之90以上。仅仅占用1毫秒,cpu占用也会降低到百分之5以下。后面还是使用wait等方法达到类似效果.
Thread t= new Thread("name"){
@override
public void run(){
while(true){
sleep(1);
log.info("业务代码");
}
}
}
t.start();
yield():静态方法。手动让出cpu,让线程重新回到就绪状态重新抢cpu的执行权,线程可能又会马上抢到cpu的时间片又进入运行状态。(没啥用)
这里插一句 sleep 与 wait 方法的区别:后面还会更为详细讲述两个方法的区别及使用,这里只是简单描述下
sleep是Thread类中的方法,wait是Objet类中的方法,sleep方法需要传入一个参数,进入有限时间等待,时间到了线程会自动唤醒。调用sleep方法是不会释放资源的(锁)。wait:方法是进入无限等待,需要通过调用notify或notifyall来唤醒。该方法会释放锁
4:其他方法
setDaemon(true):设置为守护线程,主线程一结束,守护线程无论代码是否执行完都会强制结束
getPriority():获取线程优先级。
setPriority(int):设置线程优先级。java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率。
getState():获取线程状态。Java 中线程状态是用 6 个 enum 表示,分别为: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
-
关于打断线程
我们知道sleep,wait,join这三个方法都需要处理异常,他们都会抛出InterruptedException。这是因为可以通过其他线程打断处于这些方法中的线程状态。
Thread t= new Thread("name"){
@Override
public void run(){
System.out.println("睡眠2秒");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
System.out.println("我被打断了休眠");
}
}
};
t.start();
Thread.sleep(1);//等t线程先进入睡眠状态
System.out.println("开始打断");
t.interrupt();//调用该方法打断t线程,此时t线程处于已经执行了sleep方法,处于Time_Waiting状态,此时t线程会抛出InterruptedException。
Thread.sleep(1);//这里等一毫秒是为了等t去清除打断标记,使用interruput方法来打断处于Time_Waiting状态的线程时,线程即使被打断也会清除打断标记
boolean interrupted = t.isInterrupted();//该方法返回false,因为该线程是处于Time_Waiting状态下被打断的。如果是处于Runnable状态被打断才会返回true
System.out.println("是否被打断"+interrupted);
// 清除打断标记是t线程做的事情,如果不等他做完,主线程就直接调用t.isInterrupted(),则会出现有时是true,有时是false的情况!
更好的打断线程,但是不能使用sleep方法,因为sleep后,线程进入TIME_WAITING状态,此时被打断是会清除打断标记的。
Thread t2 = new Thread(()->{
while (true){
boolean interrupted = Thread.currentThread().isInterrupted();
if(interrupted){
System.out.println("我被打断了,我自己停止吧");
System.out.println("做一些善后操作");
break;
}
//do something
System.out.println(1);
}
},"t3");
t2.start();
t2.interrupt();
如果你需要使用sleep方法,则可以使用两阶段终止的方式实现。比如:实现一个监控系统信息的线程,每隔2秒获取系统信息并输出,并提供停止监控的方法。
Thread t2 = new Thread(()->{
while (true){
// 无论线程是处于RUNNABLE状态被打断的或是TIME_WAITING状态,都可以获取到是否被打断
boolean interrupted = Thread.currentThread().isInterrupted();
if(interrupted){
System.out.println("我被打断了,停止监控");
break;
}
try {
Thread.sleep(2000);
System.out.println("输出:监控系统信息");
}catch (InterruptedException e){
// 走到这里时,线程处于RUNNABLE状态
// 由于线程是处于TIME_WAITING状态时被打断的,打断标记被清除了。这里重新设置打断标记
Thread.currentThread().interrupt();
}
}},"t3");
t2.start();
t2.interrupt();
-
关于打断park线程
LockSupport是一个工具类,提供了基本的线程阻塞和唤醒功能。LockSupport的存在目的就是为了替换掉jdk自带的wait-notify等待唤醒机制(它只能结合synchronized使用,并且只能唤醒一个或全部唤醒,不够灵活)后面还会详细介绍park方法的使用。
Thread t1 = new Thread(()->{
System.out.println("线程开始运行");
LockSupport.park();// 获取一个许可,此时许可是0,获取不到阻塞线程。
System.out.println("线程继续运行");
},"t1");
t1.start();
Thread.sleep(2000);
// 颁发一个许可,此时许可是1,线程可以获取到许可继续往下执行
LockSupport.unpark(t1);
park方法是获取一个permit(许可)。permit的初始值是0,最大值是1,最小值是0。调用park方法获取一个许可,没有获取到就会阻塞。当有线程调用unpark(t)时会给t线程颁发一个许可,从而唤醒park代码。这个方法不会抛出异常。park阻塞的线程也可以通过interruput方法来打断并设置打断标记,但是不会抛异常。
2.3 java线程的状态
关于线程状态后面还会详细讲到。这里先简单概括。我们先回顾一下操作系统中线程的状态
新建:创建了一个线程对象,此时还没有启动该线程
就绪:启动线程,具备争抢时间片的权利
运行:抢到时间片,正在cpu中执行,时间片到又会回到就绪状态重新抢,此时会发生线程上下文切换
阻塞:线程调用阻塞操作,放弃时间片,等阻塞完成后进入就绪状态重新抢时间片
死亡:线程终止。
在Thread类中有一个内部的枚举类型State,该枚举标记了java线程的6个状态
NEW:线程创建了,还没有调用start方法。
RUNNABLE:在该状态下,线程可能拿到了时间片处于运行中,也可能没拿到时间片处于就绪中,还可能调用了其他io操作处于阻塞中。(也就是说 java 没有像操作系统那样分阻塞、就绪、运行)
BLOCKED:线程没有拿到锁时会进入该状态
WAITING:无时限等待,此时线程放弃cpu,进入无休止等待,直到被唤醒。调用join()
TIMED_WAITING:有时限等待,调用sleep方法就会进入该状态
TERMINATED:线程终止
第三章:线程安全
在章节开始前,我们先搞懂什么是线程安全性,他最核心的概念就是正确性,正确性的含义是:某类的行为与其规范完全一致。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。
原子性:我们的java代码在执行 i++;这类看似一个操作时,将他编译成字节码时我们可以看到这个由3个原子操作组成的复合操作,“读取-修改-写入”,所以这个操作是非原子的,他并不是不可分割的,在多线程并发环境下就会发生由于不恰当的执行时序而出现不正确的结果的可能。这也是我们常说的竞态条件。
代码的临界区:一段代码块内如果存在对共享资源的多线程读写操作,这个代码就称为临界区。在临界区内,由于代码的执行顺序不同而导致结果无法预测,就称为发生了竞态条件。
如果当多个线程访问同一个可变的状态变量时,没有使用适当的同步,那程序就会出现错误,有三种方式可以解决这个问题。
1:不在线程之间共享该变量
2:将状态变量修改为不可变的变量
3:在访问状态变量时使用同步
什么样的对象是绝对的线程安全的呢?
该变量不会被多个线程共享(局部变量,每个线程都有一份自己的,不存在共享)
该变量不可被修改。
3.1使用synchronized解决线程安全
造成线程安全问题的主要原因有两个,一是存在共享数据,二是存在多条线程共同操作共享数据。
如果代码存在共享数据的访问,我们只需保证这个共享数据在同一时刻只有一个线程对他进行操作即可。synchronized有主要有3中使用方式:修饰代码块、修饰实例方法、修饰静态方法 (关于synchronized的原理后面再详细介绍)
修饰代码块:
Object anyThing = new Object();
synchronized( anyThing ){
// to do something
}
修饰代码块需要指定一个需要加锁的对象,他可以是任意的一个对象,每个线程在执行代码块中的代码之前都需要获取这个锁,如果该锁已经被其他线程所持有,那代码会进入等待状态,直到其他线程释放了这个锁。需要注意的是,这个锁对象在每个线程中必须是同一个。对线程的私有变量加锁是没有意义的(局部变量),每个变量都有自己的局部变量。这个锁是需要一个共享的变量。
修饰实例方法:
public synchronized void test() {
// to do something
}
synchronized作用于方法上,相当于将整个方法的代码锁住,同时使用这个方法的实例来加锁。如果你创建了两个实例来调用这个方法,这个锁是没有作用的,因为锁住的不同同一个对象。
修饰静态方法:
public synchronized static void test() {
// to do something
}
synchronized作用静态方法上,相当于使用这个类来加锁,无论使用该类创建多少个实例都可以互斥访问。类在虚拟机中仅此一份,当我们需要使用这个类来创建多个实例来并发调用该方法时,我们应该将synchronized修饰在静态方法上使用类锁,或使用一个这两个实例的共同的共享变量。
3.2 synchronize原理
想要深入了解synchronized的原理,我们需要先从jvm的视角来看一个对象的组成。对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的。
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
对象头:存储了对象的一些基本信息,它是实现synchronized的锁对象的基础。,jvm中采用2个字来存储对象头,在32位操作系统中,一个字为32bit(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其中Mark Word 在默认情况下存储着对象的HashCode、分代年龄、锁标记位等