所属专栏:Java学习
1. 多线程的概念
线程:线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程的实际运作单位
下面这些每一个能够运行的软件就是一个进程
并发:在同一时刻,有多个指令在单个CPU上交替执行
并行:在同一时刻,有多个指令在多个CPU上同时执行
2. 多线程的实现方式
2.1 继承Thread类的方式进行实现
实现方式:
1. 定义一个类,继承Thread
2. 重写run方法
3. 创建子类对象,并启动线程
public class MyThread extends Thread{
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(getName() + "hello");
}
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
//设置线程对象名称
t1.setName("线程一:");
t2.setName("线程二:");
//开启线程
t1.start();
t2.start();
}
}
在开启线程之后,会交替执行线程一和线程二
2.2 实现Runnable接口的方式进行实现
实现方式:
-
自定义一个类,实现Runnable接口
-
重写里面的Run方法
-
创建自定义类的对象
-
创建一个Thread类的对象,并开启线程
public class MyRun implements Runnable{ @Override public void run() { for(int i = 0;i < 10;i++){ //获取当前线程对象 Thread t = Thread.currentThread(); System.out.println(t.getName() + "hello"); } } }
public class ThreadDemo2 { public static void main(String[] args) { //创建MyRun对象,表示多线程要执行的任务 MyRun myRun = new MyRun(); //创建线程的对象 Thread t1 = new Thread(myRun); Thread t2 = new Thread(myRun); //给线程设置名字 t1.setName("线程一:"); t2.setName("线程二:"); //开启线程 t1.start(); t2.start(); } }
开启线程之后也是交替执行线程一和线程二
2.3 利用Callable接口和Future接口方式实现
实现方式:
1. 创建一个类MyCallable实现Callable接口
2. 重写call方法(返回值代表多线程运行的结果)
3. 创建MyCallable对象(表示多线程要执行的任务)
4. 创建FutureTask对象(作用管理多线程运行的结果)
5. 创建Thread类的对象并启动(表示线程)
import java.util.concurrent.Callable;
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
}
public class ThreadDemo3 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建MyCallable对象(表示多线程要执行的任务)
MyCallable myCallable = new MyCallable();
//创建FutureTask对象(作用管理多线程运行的结果)
FutureTask<Integer> ft = new FutureTask<>(myCallable);
//创建Thread类的对象并启动(表示线程)
Thread t1 = new Thread(ft);
t1.start();
Integer res = ft.get();
System.out.println(res);
}
}
2.4 三种实现方式对比
优点 | 缺点 | |
---|---|---|
继承Thread类 | 编程简单,可以直接使用Thread中的方法 | 可拓展性差,不能再继承其他类 |
实现Runnable接口 | 拓展性强,实现该接口同时还可以继承其他类 | 编程相对复杂,不能直接使用Thread中的方法 |
实现Callable接口 |
3. 常见的成员方法
3.1 getName()和setName()
对于setName()来说,如果没有给线程设置名称,也是有默认的名字的,格式:Thread - X(x序号从0开始)
根据Thread类的空参构造可以看出,在创建对象时的默认名称格式
那怎么在自定义类中创建对象时就传入对象名称进行构造呢?
在多线程第一种实现方式中,自定义的类继承了Thread类,但是构造方法并没有继承,所以还需要在自定义类中手动的去实现构造方法
3.2 currentThread()和sleep()
currentThread可以获取当前线程的对象
当JVM虚拟机启动之后,会自动开启多条线程,其中一条线程就叫做main线程,作用就是调用main方法,并执行里面的代码
sleep()是让线程休眠指定的时间,单位是毫秒,哪条线程执行到这个方法,那么哪条线程就会休眠,时间到了之后会继续执行下面的操作
这里的异常可以直接抛出
再来看MyThread类,这里的异常处理不能使用Throw抛出了,因为父类Thread没有抛出,这里只能使用try-catch处理异常
3.3 getPriority()和setPriority()
3.3.1 CUP的调度方式
CUP的调度方式是有两种的:分为抢占性调度和非抢占性调度
抢占式调度是一种允许高优先级线程中断低优先级线程的执行,从而立即获得CPU资源的调度方式。在这种模式下,操作系统会定期检查线程的优先级,并根据需要切换线程的执行, 对线程的访问是随机的
非抢占式调度是一种允许线程独占CPU直到其主动放弃或执行完毕的调度方式。在这种模式下,线程的执行时间由线程本身控制,调度器不会中断正在执行的线程,轮流执行线程
3.3.2 优先级
来看Thread类中优先级的设置,最小为1,最大为10,默认是5,优先级越高抢占到CUP的概率越高,只是说概率高,并不是优先级高的就肯定比优先级低的要先抢占到CUP
public class ThreadTest2 {
public static void main(String[] args) {
//创建线程要执行参数的对象
MyRunable myRunable = new MyRunable();
//创建线程对象
Thread thread1 = new Thread(myRunable,"线程一");
Thread thread2 = new Thread(myRunable,"线程二");
//获取线程优先级
System.out.println(thread1.getPriority());
thread2.setPriority(10);
System.out.println(thread2.getPriority());
System.out.println(Thread.currentThread().getPriority());
}
}
3.4 setDaemon()
设置为守护线程也类似于备胎线程,当其他线程执行完毕之后,守护线程会陆续结束(并不是立即结束)
public class ThreadTest3 {
public static void main(String[] args) {
MyThread1 thread1 = new MyThread1();
MyThread2 thread2 = new MyThread2();
thread1.setName("女神");
thread2.setName("备胎");
//把第二个线程设置为守护线程
thread2.setDaemon(true);
thread1.start();
thread2.start();
}
}
应用场景:例如在聊天软件中,当打开聊天窗口之后,开启聊天窗口的线程和发送文件的线程,这时就可以把发送文件设置为守护线程,当聊天窗口关闭之后,守护线程也就没有存在的必要了
4. 多线程的生命周期
多线程的生命周期是指一个线程从创建到消亡的整个过程。在这个过程中,线程会经历不同的状态。一般来说,线程的生命周期可以归纳为以下几个主要阶段:
1. 新建(New)
-
状态描述:线程被创建但尚未启动。使用
new
关键字和Thread
类或其子类创建一个线程对象后,该线程就处于新建状态。此时,线程仅在内存中被分配了空间,但还没有开始执行。 -
注意:在操作系统层面,真正的线程还没有被创建,只有调用了
start()
方法后,线程才会被操作系统创建并进入下一个状态。
2. 就绪(Runnable)
-
状态描述:线程已经启动,但尚未获得CPU执行权,处于等待CPU分配资源的阶段。调用线程的
start()
方法后,线程会进入就绪状态。此时,线程已经具备了运行条件,但还需要等待系统为其分配CPU资源。 -
注意:就绪状态并不是执行状态,线程需要等待CPU的调度才能进入运行状态。
3. 运行(Running)
-
状态描述:线程获得CPU资源并执行其
run()
方法中的代码。当就绪状态的线程被系统选中并获得CPU执行权时,它会进入运行状态。 -
状态转变:运行状态的线程可以转变为阻塞状态、就绪状态和死亡状态。如果线程失去了CPU资源,它会从运行状态转变为就绪状态;如果线程执行了某些导致阻塞的操作(如调用
sleep()
、wait()
方法或进行I/O操作),它会进入阻塞状态;如果线程的run()
方法执行完毕或被强制终止,它会进入死亡状态。
4. 阻塞(Blocked)
-
状态描述:线程因为某些原因(如等待I/O操作完成、等待获取同步锁等)而暂停执行。处于阻塞状态的线程不会占用CPU资源,直到阻塞的原因被消除后,线程才会重新进入就绪状态并等待CPU的调度。
-
状态转变:阻塞状态的线程可以转变为就绪状态和死亡状态。当阻塞的原因被消除(如I/O操作完成、获取到同步锁等),线程会重新进入就绪状态;如果线程在等待过程中被强制终止(如调用
stop()
方法),它会进入死亡状态。
5. 死亡(Terminated/Dead)
-
状态描述:线程的生命周期结束。当线程的
run()
方法执行完毕或被强制终止时,线程会进入死亡状态。此时,线程所占用的资源会被释放。 -
注意:死亡的线程不能被再次启动。如果尝试在死亡的线程上调用
start()
方法,会抛出IllegalThreadStateException
异常。
5. 线程安全问题
先来看一个现象:
使用多线程实现三个窗口卖票的业务
这时就出现了一些小问题,售卖的票中有相同的票,也有超出范围的票,出现这个问题的原因就是线程执行时是有随机性的,当一个线程休眠时,其他的线程就可以抢到CPU了,休眠之后就又可以争夺CPU,此时如果一个线程刚好执行到target++,还没来得及打印,其他线程抢回了CPU,并且执行了target++,这时就可能出现以上的情况
解决办法:把操作共享数据的代码锁起来,锁默认打开,如果有现成进去之后,锁自动关闭,里面的代码全部执行完毕,线程出来,锁自动打开,这样就可以解决上述问题
5.1 同步代码块
同步代码块是通过关键字synchronized来实现的,括号中需要传入一个锁对象,可以是任意的,但必须是唯一的,通常会使用Thread.class作为锁对象,因为字节码文件对象是唯一的
synchronized (锁对象){
}
public class MyThread3 extends Thread {
static int ticket = 0;
@Override
public void run() {
while (true) {
synchronized (Thread.class) {
if (ticket < 100) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println("正在卖第" + ticket + "张票");
} else {
break;
}
}
}
}
}
要注意的是,synchronized在这里不能写在while循环外面,不然的话只有线程一就把循环的内容执行完了,然后剩余的线程由于target不满足循环条件,就不会再执行了
5.2 同步方法
把synchronized加在方法上就是同步方法
格式:修饰符 synchronized 返回类型方法名(方法参数){...};
特点:同步方法是锁住方法里面所有的代码,锁对象不能自己指定,在非静态方法中,锁对象为this所指的对象,在static静态方法中,锁对象指的是当前类的字节码文件的对象
还是上面的例子,这次 实现 Runnable接口,使用同步方法试一下
public class MyRunnable implements Runnable {
int ticket = 0;
@Override
public void run() {
while (true) {
if (func()) break;
}
}
private synchronized boolean func() {
if (ticket == 100) {
return true;
} else {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticket++;
System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票");
}
return false;
}
}
6. 锁
上面的同步代码块和同步方法虽然也是起到了把一段代码锁起来的效果,但是并没有直接看出哪里加上了锁,哪里释放了锁,为了更清晰的表达如何加锁和释放锁,JDK5以后提供了一个新的锁对象Lock,Lock实现提供比使用synchronized方法和语句可以获得更广泛的锁定操作
Lock中也提供了获得锁和释放锁的方法
void lock() : 获得锁
void unlock() : 释放锁
Lock是一个接口,所以需要通过它的实现类ReentrantLock来实例化对象,然后再调用上面两个方法
以之前创建的MyThread3为例,由于需要创建三个MyThread3的对象,所以在MyThread3中创建的锁对象也会被创建三次,那么就会出现之前超出范围的问题,所以创建的锁对象要用static修饰一下
但这时会出现一个问题,程序最终并没有停止
这是因为假如线程一抢到了CPU,并执行完毕之后跳出了循环,线程二和线程三还在锁的外面,所以需要改变释放锁的位置,可以利用 finally 来解决这个问题
public class MyThread3 extends Thread {
static int ticket = 0;
static Lock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
//synchronized (Thread.class) {
lock.lock();
try {
if (ticket == 100) {
break;
} else {
Thread.sleep(10);
ticket++;
System.out.println(getName() + "正在卖第" + ticket + "张票");
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
// }
}
}
}
6.1 死锁
它指的是两个或多个线程在执行过程中,由于竞争资源而造成的一种阻塞现象,若无外力作用,这些线程都将无法向前推进,死锁通常发生在两个或多个线程相互等待对方释放锁的情况,一般就是在锁的嵌套中容易发生,所以要避免这种写法
7. 等待唤醒机制
void wait() | 当前线程等待,直到被其他线程唤醒 |
---|---|
void notify() | 随机唤醒单个线程 |
void notifyAll() | 唤醒所有线程 |
等待(wait):当一个线程执行到某个对象的wait()方法时,它会释放当前持有的锁(如果有的话),并进入等待状态。此时,线程不再参与CPU的调度,直到其他线程调用同一对象的notify()或notifyAll()方法将其唤醒。
唤醒(notify/notifyAll):
notify: 唤醒在该对象监视器上等待的某个线程,如果有多个线程在等待,那么具体唤醒哪一个是随机的
notifyAll: 唤醒在该对象监视器上等待的所有线程
调用wait方法的线程会释放其持有的锁,被唤醒的线程在执行之前,必须重新获取被释放的锁
public class Cook extends Thread {
@Override
public void run() {
while (true) {
synchronized (Desk.lock) {
if (Desk.count == 0) {
break;
} else {
if (Desk.foodFlag == 0) {
try {
Desk.lock.wait();//厨师等待
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
Desk.count--;
System.out.println("还能再吃" + Desk.count + "碗");
Desk.lock.notifyAll();//唤醒所有线程
Desk.foodFlag = 0;
}
}
}
}
}
}
public class Foodie extends Thread {
@Override
public void run() {
while (true) {
synchronized (Desk.lock) {
if (Desk.count == 0) {
break;
} else {
if (Desk.foodFlag == 1) {
try {
Desk.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
System.out.println("已经做好了");
Desk.foodFlag = 1;
Desk.lock.notifyAll();
}
}
}
}
}
}
public class Desk {
public static int foodFlag = 0;
public static int count = 10;
//锁对象
public static Object lock = new Object();
}
这里实现的功能就是,厨师做好事务放在桌子上,美食家开始品尝,如果桌子上没有食物,美食家就等待,有的话,厨师进行等待
7.1 阻塞队列
在Java中,阻塞队列(BlockingQueue)是java.util.concurrent包下的一个接口,它支持两个附加操作的队列。这两个附加的操作是:在元素从队列中取出时,如果队列为空,则等待直到队列中有元素可取;在元素添加到队列时,如果队列已满,则等待直到队列中有空间可用。阻塞队列主要用于生产者-消费者场景,其中生产者线程用于向队列中添加元素,而消费者线程从队列中取出元素。
-
生产者-消费者场景:在多线程环境下,生产者线程向队列中添加元素,消费者线程从队列中取出元素。阻塞队列能够平衡生产者和消费者的处理能力。
-
任务队列:在异步处理框架中,将待处理的任务放入阻塞队列中,由线程池中的线程去取任务并执行。
public class Cook1 extends Thread{
ArrayBlockingQueue<String> queue;
public Cook1(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true){
try {
queue.put("美食");
System.out.println("已经做好了一道美食");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Foodie1 extends Thread{
ArrayBlockingQueue<String> queue;
public Foodie1(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true){
try {
String food = queue.take();
System.out.println(food);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadTest6 {
public static void main(String[] args) {
//必须指定阻塞队列的上限
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1);
Cook1 cook1 = new Cook1(queue);
Foodie1 foodie1 = new Foodie1(queue);
cook1.start();
foodie1.start();
}
}
在Cook1和Foodie1两个类中,发现并没有去使用同步代码块或是锁,因为take()和put()这两个方法在底层已经使用到了锁,如果再使用锁就会发生死锁
此外,由于锁是在方法内部使用的,所以方法外面的打印语句由于CPU抢占的原因,可能发生之前重复打印的情况
8. 多线程的六种状态
多线程的六种状态分别为:新创建,可运行,被阻塞,等待,计时等待,被终止
在Java中是没有定义运行状态的,因为当线程抢夺到CPU的执行权之后,接下来就该交给操作系统了,也没有必要再定义了
9. 线程池
9.1 线程池的概念和使用
在之前我们写的代码中,用到线程就创建,用完之后线程就消失了,这样会浪费操作系统的资源,也存在一些弊端,通过线程池就可以解决这个问题
线程池是一种线程使用模式,它维护着多个线程,等待着监督管理者分配可并发执行的任务
线程池的核心原理:
创建一个空的线程池
提交任务时,线程会创建新的线程对象,任务分配完毕,线程归还给线程池,下次再提交任务时,不需要创建新的线程,直接复用已有的线程即可
如果提交任务时,线程池中没有空闲线程,也无法创建新的线程,任务就会排队等待
public static ExecutorService newCachedThreadPoll() | 创建一个没有上限的线程池 |
---|---|
public static ExecutorService newCachedThreadPoll(int nThread) | 创建有上限的线程池 |
先来正常的获取线程池对象,提交任务,销毁线程
public class ThreadTest7 {
public static void main(String[] args) throws InterruptedException {
//获取线程池对象
ExecutorService pool1 = Executors.newCachedThreadPool();
//提交任务
pool1.submit(new MyRunable());
//销毁线程池
pool1.shutdown();
}
}
线程复用:
线程池中的线程是复用的,一旦一个线程完成了它的任务,他就会回到线程池中等待下一个任务,如果任务提交的速度不快,或者线程池的配置较小,那么就可能看到同一个线程被用来执行多个任务
例如上面的代码中,每次提交任务,如果线程池中有空闲的线程,就会复用,而不是创建新的线程
9.2 自定义线程池
通过上面介绍的静态方法创建出来的线程池不够灵活,如果说等待的线程过多,阻塞队列中已经排满了线程,这时修改起来就不好操作,使用自定义线程池可以对所有现成进行同一管理和监控,便于及时发现问题,并及时进行配置和调整
创建自定义线程池用到的参数:核心线程的数量,线程池中最大线程池的数量,空闲时间(值),空闲时间(单位),阻塞队列,创建线程的方式,要执行的任务过多时的解决方案
解释:核心线程就是指一直存在于线程池中的线程,两个空闲时间就是值,创建出来的临时线程空闲的时间,超过这个时间就意味着这靠核心线程就足以完成当前提交的任务,就需要销毁临时线程,节约资源,要执行的任务过多时的解决方案指的是,当前线程池中线程的数量已经达到了最大,并且阻塞队列也已经排满了,就需要把多出来的任务踢出去
不断地提交任务,会有以下三个临界点:
1. 当核心线程满了之后,再提交任务就会排队
2. 当核心线程和阻塞队列都满了之后,就会创建临时线程
3. 当核心线程,阻塞队列,临时线程都满了之后,会触发任务的拒绝策略
任务拒绝策略:默认使用丢弃任务并抛出RejectedExecutionException异常
public class MyThreadPoolDemo1 {
public static void main(String[] args) {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
3,//核心线程数量,不能小于0
6,//最大线程数,不能小于0,大于等于核心线程数
60,//最大存活时间
TimeUnit.SECONDS,//单位,用TimeUnit指定
new ArrayBlockingQueue<>(3),//阻塞队列
Executors.defaultThreadFactory(),//创建线程工厂
new ThreadPoolExecutor.AbortPolicy()//AbortPolicy是ThreadPoolExecutor的静态内部类
);
}
}
最大并行数是指计算机系统或软件在处理任务时能够同时执行的最大指令或数据数量。
线程池多大合适:
CPU密集型运算:计算比较多,采用最大线程数 + 1
I/O密集型运算:如果读取本地文件或读取数据库的操作比较多