【Java学习笔记十二】——初窥多线程
声明:本文章内容主要摘选自尚硅谷宋红康Java教程、《Java核心卷一》、廖雪峰Java教程,示例代码部分出自本人,更多详细内容推荐直接观看以上教程及书籍,若有错误之处请指出,欢迎交流。
一、多线程的概念
读者可能已经很熟悉操作系统中的多任务(multitasking)
:在同一刻运行多个程序的能力。例如,在编辑或下载邮件的同时可以打印文件。今天,人们很可能有单台拥有多个CPU的计算机,但是,并发执行的进程数目并不是由CPU数目制约的。操作系统将CPU的时间片分配给每一个进程,给人并行处理的感觉。
多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。通常,每一个任务称为一个线程(thread)
,它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)
。
那么,多进程与多线程有哪些区别呢?本质的区别在于每个进程拥有自己的一整套变量,而线程则共享数据。这听起来似乎有些风险,的确也是这样,在稍后将可以看到这个问题。然而,共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
在实际应用中,多线程非常有用。例如,一个浏览器可以同时下载几幅图片。一个Web服务器需要同时处理几个并发的请求。图形用户界面(GUI)程序用一个独立的线程从宿主操作环境中收集用户界面的事件。
二、创建多线程
1.创建
方式一:继承于Thread类
- 创建一个继承于Thread类的子类
- 重写Thread类的run()-->将此线程执行的操作声明在run()中
- 创建Thread类的子类的对象
- 通过此对象调用start()
方式二:实现Runnable接口
- 创建一个实现了Runnable接口的类
- 实现类去实现Runnable中的抽象方法:run()
- 创建实现类的对象
- 将此对象作为参数传送到Thread类的构造器中,创建Thread类的对象
- 通过Thread类的对象调用start()
public class Main {
public static void main(String[] args) {
MyThread1 t = new MyThread1();
MyThread2 q = new MyThread2();
Thread p = new Thread(q);
t.setName("Ⅰ线程一");//设置线程名称
p.setName("Ⅱ线程二");//设置线程名称
t.start();
p.start();
}
}
//方式一创建多线程
class MyThread1 extends Thread{
public void run(){
for (int i = 0; i < 100; i++) {
if(i % 2 == 0) System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
//方式二创建多线程
class MyThread2 implements Runnable{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0) System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
注意:不要调用Thread类或Runnable对象的run方法。直接调用run方法,只会执行同一个线程中的任务,以串行的方式进行,实际上运行的线程是main而不会启动新线程。应该调用Thread.start方法。这个方法将创建一个执行run方法的新线程。
2.线程的状态
在Java程序中,一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行
run()
方法的Java代码; - Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行
sleep()
方法正在计时等待; - Terminated:线程已终止,因为
run()
方法执行完毕。
线程终止的原因有:
- 线程正常终止:
run()
方法执行到return
语句返回; - 线程意外终止:
run()
方法因为未捕获的异常导致线程终止; - 对某个线程的
Thread
实例调用stop()
方法强制终止(强烈不推荐使用)。
一个线程还可以等待另一个线程直到其运行结束。例如,main
线程在启动t
线程后,可以通过t.join()
等待t
线程结束后再继续运行。
3.解决线程安全问题
如果多个线程同时读写共享变量,会出现数据不一致的问题。例如采用4个线程售卖100张彩票,在运行的时候会出现彩票的数量变成负数的情况,原因就是各个线程的数据并不同步,我们称之为线程不安全,以下有几种方法可以解决这个问题。
方式一:同步代码块
首先我们先来知道锁的概念。多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待。通过加锁和解锁的操作,就能保证在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized
关键字对一个对象进行加锁:
synchronized(同步监视器){//需要被同步的代码}
public class Thread2 {
public static void main(String[] args) {
myThread3 queue = new myThread3();
Thread window1 = new Thread(queue);
Thread window2 = new Thread(queue);
Thread window3 = new Thread(queue);
window1.setName("1号窗口");
window2.setName("2号窗口");
window3.setName("3号窗口");
window1.start();
window2.start();
window3.start();
}
}
class myThread3 implements Runnable{
private volatile int ticket = 100;
private volatile Object obj = new Object();
public void run(){
while(true){
synchronized(obj){
if(ticket > 0){
try{
Thread.sleep(100);
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":卖票,票号:" + ticket);
ticket--;
}else{
break;
}
}
}
}
}
线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile
声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true
,线程1执行a = false
时,它在此刻仅仅是把变量a
的副本变成了false
,主内存的变量a
还是true
,在JVM把修改后的a
回写到主内存之前,其他线程读取到的a
的值仍然是true
,这就造成了多线程之间共享的变量不一致。
因此,volatile
关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile
关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
方式二:同步方法
class myThread3 implements Runnable{
private volatile int ticket = 100;
private volatile Object obj = new Object();
public void run(){
while(true){
sale();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void sale(){
if(tickets > 0) {
System.out.println(Thread.currentThread().getName() + ":卖票,票号:" + ticket--);
}
}
}
方式三:线程池
使用线程池创建线程,有以下好处:(此处简略介绍,后面会有另外一篇笔记涉及到线程池)
-
提高响应速度(减少了创建新线程的时间)
-
降低资源消耗(重复利用线程池中线程,不需要每次都创建)
-
便于线程管理
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class MyThread4 implements Runnable {
@Override
public void run() {
for (int i = 0; i < 20; i++) {
if(i % 2 == 0) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class Thread3 {
public static void main(String[] args) {
//1.提供指定线程数量的线程池
ExecutorService service = Executors.newFixedThreadPool(10);
//2.执行指定的线程的操作。需要提供Runnable接口实现类的对象
service.execute(new MyThread4());
// service.submit();//适合用于Callable
service.shutdown();//关闭线性池
}
}
4.守护线程
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
因此,JVM退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
Thread t = new MyThread();t.setDaemon(true);t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
普通线程的结束是以run方法结束;
守护线程的结束是以run方法或main函数结束.
import java.util.concurrent.TimeUnit;
public class ThreadTest3 {
public static void main(String[] args) throws InterruptedException {
MyThread1 t = new MyThread1();
t.setDaemon(true);//守护线程
t.start();
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " is exiting");
}
}
class MyThread1 extends Thread{
public void run() {
while(true){
System.out.println(Thread.currentThread().getName() + " is running");
try {
TimeUnit.SECONDS.sleep(1);//与Thread.sleep()类似,只是单位换成了秒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
三、死锁
一个线程可以获取一个锁后,再继续获取另一个锁,在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
- 线程1:进入
add()
,获得lockA
; - 线程2:进入
dec()
,获得lockB
。
随后:
- 线程1:准备获得
lockB
,失败,等待中; - 线程2:准备获得
lockA
,失败,等待中。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序,改写dec()
方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
下一篇笔记会涉及多线程的高级操作,如并发框架Executor,Fork-Join等,若不满足于以上操作,可以看下一篇笔记~
最后附一下相关链接:
Java在线API中文手册
Java platform se8下载
尚硅谷Java教学视频
《Java核心卷一》
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!