在Java中如何使用多线程,在使用的过程中又有哪些需要注意的事项呢?本文带着学习的目的,去探索、解析Java中的多线程编程,感兴趣的话就一起来看看吧。
创建线程的方式
一般来说,创建线程的方式主要有两种:
直接继承Thread类,重写run()方法,并在线程对象上调用start()方法来开启线程;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class TestThread1 extends Thread {
public void run() {
for (int i = 0; i < 20; ++i) {
System.out.println("线程1 --- " + i);
}
}
public static void main(String[] args) {
TestThread1 testThread1 = new TestThread1();
testThread1.start();
for (int i = 0; i < 10; ++i) {
System.out.println("主线程 --- " + i);
}
}
}实现Runnable接口,实现run()方法,将Runnable实现类放入线程对象的构造函数中,线程对象调用start()方法启动线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class TestThread3 implements Runnable{
public void run() {
for (int i = 0; i < 20; ++i) {
System.out.println("线程3 --- " + i);
}
}
public static void main(String[] args) {
TestThread3 testThread3 = new TestThread3();
new Thread(testThread3).start();
for (int i = 0; i < 10; ++i) {
System.out.println("主线程 --- " + i);
}
}
}实现Callable接口,步骤比较繁琐:
(1)实现Callable接口,确定返回值类型;(2)重写call方法,确定抛出的异常类型;(3)创建目标对象;(4)创建执行服务:ExecutorService;(5)提交执行;(6)获取结果;(7)关闭服务。
以多线程下载网络图片为例:
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76package com.zzmine.mulThread;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 线程创建方式三:实现Callable接口
public class TestCallable implements Callable<Boolean> {
public Boolean call() throws Exception {
WebDownloader webDownloader = new WebDownloader();
webDownloader.downloader(url, name);
System.out.println("下载文件:" + name);
return true;
}
private String url;
private String name;
public TestCallable(String url, String name){
this.url = url;
this.name = name;
}
public static void main(String[] args) {
String urls[] = {
"https://y.zdmimg.com/202310/29/653d3f851f6059394.jpg_d200.jpg",
"https://y.zdmimg.com/202310/29/653d3f851f6059394.jpg_d200.jpg",
"https://y.zdmimg.com/202310/29/653d3f851f6059394.jpg_d200.jpg"
};
String filenames[] = {
"pic1.jpg",
"pic2.jpg",
"pic3.jpg"
};
TestCallable t1 = new TestCallable(urls[0], filenames[0]);
TestCallable t2 = new TestCallable(urls[1], filenames[1]);
TestCallable t3 = new TestCallable(urls[2], filenames[2]);
// 创建执行服务
ExecutorService ser = Executors.newFixedThreadPool(3);
// 提交执行
Future<Boolean> r1 = ser.submit(t1);
Future<Boolean> r2 = ser.submit(t2);
Future<Boolean> r3 = ser.submit(t3);
// 获取结果
try {
boolean rs1 = r1.get();
boolean rs2 = r2.get();
boolean rs3 = r3.get();
} catch (Exception e) {
e.printStackTrace();
}
// 关闭服务
ser.shutdown();
}
}
class WebDownloader{
public void downloader(String url, String name){
try {
FileUtils.copyURLToFile(new URL(url), new File(name));
} catch (IOException e) {
e.printStackTrace();
System.out.println("IO异常,downloader方法出现问题");
}
}
}
一般来说,第二种方式更加灵活,而且Java单继承多实现,实现Runnable接口不会消耗继承的次数。推荐使用第二种方式创建线程。
而对于Callable
接口和Runnable
接口的对比如下:
优点:
- 返回结果:
Callable
接口的call
方法可以返回执行结果,而Runnable
接口的run
方法没有返回值。- 抛出异常:
Callable
接口的call
方法可以抛出异常,而Runnable
接口的run
方法不能抛出受检异常。- 更强大的功能:
Callable
接口与Future
接口结合使用,可以在多线程任务完成后获取结果、取消任务或查询任务是否已经完成。缺点:
- 繁琐: 使用
Callable
需要与FutureTask
配合使用,会增加一些代码复杂度。- 不直接支持线程池: 直接使用
Callable
需要将其包装成Runnable
才能提交给线程池执行。
总体来说,Callable
接口相比Runnable
接口更适用于需要返回结果、处理异常等场景,但也带来了一些额外的复杂性。选择使用哪种接口应该根据具体的需求和场景来决定。
多线程并发问题
我们知道多线程并发时会产生许多问题,比如:竞态条件(对共享资源的访问顺序不确定)、活跃性问题(饥饿、死锁)、资源争夺以及数据不一致(访问共享的资源却没有同步机制)。这里以数据不一致(一个多线程抢火车票的案例),来具体看一下多线程并发的问题。
1 | public class TestThread4 implements Runnable { |
从结果来看,已经出现了非常明显的问题了。包括:(1)同一张票有多人抢到;(2)总票数为10,实际却卖出了15张票;(3)票的编号甚至出现了0和负数。这便是种种数据不一致的问题!
Java多线程访问共享资源出现数据不一致的并发问题,其根源在于“缓存一致性”。
缓存一致性是指 多核处理器或者多处理器系统中的各级缓存与主内存中数据的一致性。
当多个线程同时访问共享资源时,每个线程都会将共享的资源在自己的缓存空间中留一个副本(副本来自于主内存)。当一个线程修改了这个共享资源,其他线程可能仍然在使用自己缓存中旧的值,从而导致数据不一致。
多线程设计思想:静态代理模式
让我们来思考一个问题,从多线程的创建方式 new Thread(Runnable对象实例,线程名).start();
这行代码中,你是否能够看出多线程Thread模型的设计思想呢?
其实,Thread的设计思想是一种静态代理模式。首先来瞧瞧什么是静态代理模式吧。以结婚为例,涉及的角色包括:①结婚对象;②婚庆公司。在过程中婚庆公司会帮结婚对象处理很多事宜,具体结婚的过程还可以由结婚对象来自己定制。示例代码如下:
1 | package com.zzmine.mulThread; |
故所谓静态代理模式,满足两点:
- 真实对象和代理对象都要实现同一个接口
- 代理对象要代理真实对象
其模式的好处在于:(1)代理对象可以做很多真实对象做不了的事情;(2)真实对象只需要专注自己的事情。
让我们在把目光放到Thread与静态代理模式的联系上。把静态代理模式创建方式和Thread多线程创建方式放在一起来看:
1 | // Thread多线程创建 |
是不是就感觉很像、很亲切了呢。而WeddingCompany是代理了结婚对象在结婚过程中的前后事宜,那么Thread则是帮助线程任务布置以及处理现场。
具体地,从Thread类源码也可以看出来静态代理模式。Thread类也是实现了Runnable接口,并且有一个Runnable类型的私有成员变量target,在Thread调用start()方法后,会执行target的run()方法,具体调用栈是这样的:
- 当调用
start()
方法时,会进入start0()
方法。 start0()
方法是一个本地方法(native method),它通过 JNI 与底层的操作系统进行交互,实现线程的创建和启动。- 在底层操作系统中,会调用相应的线程管理函数来创建新的线程,并将
run()
方法作为新线程的入口点。 - 新线程被创建并启动后,会执行
run()
方法内的逻辑。
所以整个调用流程可以简化为:start() -> start0() -> run()
。
简化构造:Lambda表达式
Lambda(λ)是希腊字母表排序第十一位的字母,Lambda表达式其实属于函数式编程的概念。其形式如下:
1 | (params) -> expression [表达式] |
例如:new Thread(()->System.out.println(“Java多线程”)).start;
Lambda表达式具备这样的优势:(1)避免匿名内部类定义过多;(2)去掉无意义代码,只留下核心逻辑,更简洁。
学习Lambda表达式的关键在于:理解Functional Interface(函数式接口)
函数式接口:
任何接口,如果只包含唯一一个抽象方法,那么它就是一个函数式接口。例如:
1
2
3 public interface Runnable{
public abstract void run();
}对于函数式接口,可以通过Lambda表达式来创建该接口的对象。
下面来看一看Lambda表达式是如何一步一步演进的,下面的编号从小到大代表着演进的过程,其中的目标都是简化代码、避免定义仅使用一次的类。
1 | package com.zzmine.lambda; |
可以看到这样的演进路径:
- 外部实现类。需要在外部定义实现类,包内可见。
- 静态内部类。当前类内部的静态类,可通过类.来访问。
- 局部内部类。方法中定义的内部类,作用域局限于代码块中。
- 匿名内部类。没有类名,必须依靠接口或者父类实现。
- Lambda表达式,函数式接口。不再以类的形式存在。
上面演示的接口是不带参数的,如果是带参数的接口,则有如下的使用规则:
- Lambda表达式在只有一行的时候可以去掉花括号,否则需要代码块包裹
- 多个参数也可去掉参数类型,要去掉都去掉,且要加圆括号
1 | // 标准使用 |
线程状态
线程的几种基础状态包括:
停止线程
JDK提供的stop()、destory()方法已废弃,不推荐使用。
推荐手动让线程停止,使用一个标志位作为终止变量,来使线程停止运行。
1 | public class TestStop implements Runnable{ |
线程休眠
Thread.sleep()
可以指定当前线程阻塞的毫秒数,用于模拟网络延迟或者倒计时,还可以放大并发问题的发生性,存在异常InterruptedException。sleep时间结束后线程进入就绪状态。
例如,使用sleep模拟倒计时:
1 | public static void tenDown() throws InterruptedException { |
需要记住的是:每个对象都有一个锁,sleep并不会释放锁。
线程礼让
yield 礼让线程,是一个本地(native)方法,让当前正在执行的线程暂停(从运行态转化为就绪态),但不阻塞。让CPU重新调度,但是礼让不一定成功。
合并线程
join 合并线程,等待此线程执行完成后(其他线程阻塞),再执行其他线程。可以理解为插队,是比较霸道的方法。
1 | public class TestJoin implements Runnable{ |
在这个示例中,可以看到主线程执行到200时,强制先跑完插队的thread线程,再去执行剩余的主线程。
线程状态总结
Thread.State
标记线程状态,线程可以处于以下状态之一:
NEW:尚未启动的线程处于此状态。
RUNNABLE:在Java虚拟机中执行的线程处于此状态。
BLOCKED:被阻塞等待监视器锁定的线程处于此状态。
WAITING:正在等待另一个线程执行特定动作的线程处于此状态。
TIMED_WAITING:正在等待另一个线程动作达到指定等待时间的线程处于此状态。
TERMINATED:已退出的线程处于此状态。
线程在给定的时间点只能处于一种状态。这些状态是虚拟机状态,不反映任何操作系统线程状态。
1 | public class TestState { |
另外注意的是,停止之后的线程是不能再次启动的。
线程的优先级
Java提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程,线程调度器按照优先级决定应该调度哪个线程来执行。线程优先级用数字表示,范围为[1,10]。使用getPriority()获取优先级、setPriority(int xx)改变优先级。
main主线程是默认优先级5,不可更改。
注意:(1)需要先设置优先级,再启动线程;(2)并不是优先级更高的总是会优先执行,只是大多数时候会优先。
守护线程
守护线程 daemon。线程分为用户线程和守护线程。虚拟机必须确保用户线程(e.g. main线程)执行完毕,但是不用等待守护线程(e.g. gc线程)执行完。例如下面的例子,一个线程执行人的一生,另一个线程执行上帝保佑着你,看似上帝线程无限执行,但是设置成了守护线程,当人的线程跑完,虚拟机不会管上帝线程是否还在执行,都会停止。
1 | public class TestDaemon { |
守护线程的应用场景:后台记录操作日志,监控内存,垃圾回收,等待机制,监控机制等。
守护线程也是一种类似的线程状态,由一个boolean类型的值来标识,可以通过setDaemon(boolean xx);来设置,默认为false。
线程同步机制
线程同步,即解决多个线程操作同一个资源,应该如何处理好的问题。对于资源对象来说,是并发问题,被多个线程同时操作。
同步机制其实就是:排队等待,一个一个地来。
多个需要同时访问某一个对象的线程进入到这个对象的等待池中形成队列,等前面线程使用完毕,下一个线程再使用。
有了队列,还需要 锁机制(synchronized),来解决安全性问题。某个线程排到队了,就需要加锁保证处理过程中不会被其他线程干扰。
当一个线程获取对象的排它锁,独占资源,其他线程必须等待使用后释放锁才可以继续。
存在锁来保证安全,与此同时也会存在一些问题:
- 一个线程持有锁,会导致其他所有需要此锁的线程挂起;
- 在多线程竞争下,加锁、释放锁会导致 频繁的上下文切换 和 调度延时,引起性能问题;
- 如果优先级高的线程等待优先级低的线程释放锁,会导致优先级导致,引发性能问题。
不安全集合 ArrayList 的示例:
1 | public static void main(String[] args) { |
结果可见,最终集合的大小并不是10000,而是小于的,每次执行结果也不一样。
同步方法和同步块
需要保证数据变量被同步访问,其实可以这么想:因为用private能保证数据对象只被方法访问,所以可以针对方法提出一套机制来同步。这个机制就是 synchronized
关键字,它包括两种用法 synchronized方法 和 synchronized块。
例如synchronized方法,public synchronized void method(int args){ }
synchronized方法
其实控制对“对象”的访问,每个对象都有一把锁,synchronized方法调用时必须获得调用该方法的对象(this)的锁才能执行,否则线程就会阻塞;方法一旦执行,便独占该锁。
缺陷:(1)如果将一个大的方法声明为synchronized,将会影响效率。(2)synchronized方法只能锁this对象。
另外一种方式,同步块 synchronized(obj){ }。可以用来锁非this对象,obj可以为任意对象。也被成为“同步监视器”。
例如,上述不安全集合可以将list锁住,最后停滞1s输出,就能够看到10000的集合大小了。
Lock(锁)
JDK5.0新特性,通过显示定义“同步锁对象”来实现同步。同步锁对象(java.util.concurrent.locks.Lock)接口,提供对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
ReentrantLock(可重入锁)类实现了Lock,拥有与synchronized相同的并发性和内存语义,比较常用。例如,用ReentrantLock来修改抢票的例子,使其线程安全。
1 | package com.zzmine.sync; |
synchronized 与 Lock的对比:
- Lock是显式锁(需手动开关);synchronized是隐式锁,出了作用域自动释放。
- Lock只能锁代码块;synchronized有代码块和方法锁两种形式。
- 使用Lock锁,JVM花费更少时间调度线程,性能更好,并且具有更好扩展性(丰富的子类)。
- 建议使用顺序:Lock锁 > 同步代码块 > 同步方法
Java中的JUC包中的安全集合:CopyOnWriteArrayList
JUC指的是java.util.concurrent,它是Java编程语言中用于处理并发编程的工具包。其中有许多工具类,包括Callable、安全集合CopyOnWriteArrayList等。例如:
1 | public static void main(String[] args) { |
死锁
死锁:指两个或以上的进程在执行过程中,由于竞争资源而导致的阻塞现象,若没有外力的作用,他们都将无法推进下去。
死锁必须同时满足四个条件才会发生:
(1)资源互斥:多个线程不能同时使用同一个资源;
(2)占有并等待:线程在等待资源的时候也占有一些资源;
(3)不可剥夺:在线程自己使用完资源之前,不能被其他线程获取;
(4)循环等待:多个线程获取资源的顺序构成了环形链。
例如,一个死锁的示例:
1 | package com.zzmine.sync; |
线程协作:生产者消费者问题
- 生产者:没有生产产品之前,通知消费者等待;生产产品后,通知消费者消费。
- 消费者:再消费之后,通知生产者已经消费结束。
问题分析:这也是一个线程同步问题,生产者和消费者共享同一个资源,并且生产者和消费者之间相互依赖、互为条件。synchronized虽然能解决同步问题,但是不能实现不同线程之间的通信。
线程通信:Java提供了几个方法来解决线程之间的通信问题
方法名 | 作用 |
---|---|
wait() | 表示线程一直等待,直到其他线程通知。与sleep不同,这会释放锁 |
wait(long timeout) | 指定等待的毫秒数 |
notify() | 唤醒一个处于等待状态的线程 |
notifyAll() | 唤醒同一个对象上所有调用wait()方法的线程,优先级高的线程优先调度。 通常来说更安全 |
wait()
方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object
类的一个native
方法,也就是由JVM的C代码实现的。其次,必须在synchronized
块中才能调用wait()
方法,因为wait()
方法调用时,会释放线程获得的锁,wait()
方法返回后,线程又会重新试图获得锁。
同时,已被notify
唤醒的线程还需要获得锁才能继续执行
注意:这些均是Object类的方法,都只能在同步方法或者同步代码块中使用,否则会抛出异常IllegalMonitorStateException.
解决方式一:管程法
引入一个缓冲区:消费者不直接使用生产者的数据,他们之间有一个“缓冲区”。生产者将生产好的数据放入缓冲区,消费者再从缓冲区拿出数据。
示例:缓冲区大小为10
1 | package com.zzmine.sync; |
相关参考:使用wait和notify - 廖雪峰的官方网站 (liaoxuefeng.com)
疑惑:
-
为什么参考中的示例,getTask()判断queue.isEmpty()一定要用while循环?
提问参考:问一个线程通信的问题 - V2EX,wait()被唤醒后,还是继续执行后续逻辑,而不是重新将整个方法执行一次[Java中进入wait状态的线程被唤醒后会接着上次执行的地方往下执行还是会重新执行临界区的代码_java this.wait();之后还会执行下面的操作吗-CSDN博客]
-
wait()方法的实现过程?
Object.wait()
是 Java 中用于线程同步的方法之一。它使当前线程等待,直到另一个线程调用相同对象上的notify()
或notifyAll()
方法。wait()
的实现过程如下:- 当调用
wait()
方法时,当前线程会释放对象的锁,并进入等待状态。 - 等待状态的线程会加入对象的等待队列中,等待其他线程调用
notify()
或notifyAll()
来唤醒它。 - 当其他线程调用对象上的
notify()
或notifyAll()
方法时,等待队列中的线程将被唤醒,重新竞争对象的锁。 - 一旦等待的线程获得了对象的锁,它就可以继续执行。
需要注意的是,
wait()
方法必须在同步块或同步方法中使用,并且通常与synchronized
关键字一起使用,以确保在调用wait()
方法前后能正确地获取和释放对象的锁。 - 当调用
解决方式二:信号灯法
增加一个标志位:如果标志为true,则处理逻辑;如果为false则进行等待。标志位方法还是比较简单的,示例代码:
1 | package com.zzmine.sync; |
线程池
背景:经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。
思路:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。
好处:
- 提高响应速度(减少创建新线程的时间)
- 降低资源消耗(重复利用线程池中的线程)
- 便于线程管理(线程池的很多参数配置)
主要是这两个类(JDK5.0起提供):java.util.concurrent.ExecutorService
和 java.util.concurrent.Executors
;
ExecutorService: 真正的线程池接口
- void execute(Runnable command): 执行任务,没有返回值
- <T>Future<T> submit(Callable<T> task): 执行任务,有返回值
- void shutdown(): 关闭连接池
Executors: 工具类、线程池的工厂类,用于创建并返回不同类型的线程池。
其他多线程练习
多线程奇偶交替打印1-100
1 | // 1-100, 多线程奇偶交替打印 |