目录
一. 什么是多线程
1. 多线程概念
提到多线程,首先要学习一些概念性的词汇,线程和进程,并行和并发。
进程:一个正在系统上执行的程序为一个进程,一个进程包含多个线程。
线程:线程是操作系统能够进行运算调度的最小单位,多个线程会存在共享数据的情况。
并行:并行是实现多任务处理的方式。并行处理可以提高处理速度和效率,因为多个任务可以在同一时刻同时进行处理。在计算机领域,通常是通过多核处理器、GPU等技术来实现并行处理。并行处理的经典例子是在多核处理器上同时进行多个计算任务。
并发:多个程序或任务在同一时间段内同时执行的能力。在操作系统中,并发通常指的是多个程序在同一个处理机上运行,但任一时刻只有一个程序在处理机上运行。这些程序在宏观上看起来是同时运行的,但在微观上,它们是分时交替执行的。
2. jvm内存区域介绍
jvm内存模型的详细内容,可以通过上面文章了解。
简单来说,一个线程就是一个栈,他是线程不共享的,没有线程安全问题。而jvm最大的内存区域堆就不一样了,由于他线程共享的特点,因此在处理多线程问题的时候,共享的区域在不添加任何保护手段的情况下就会出现异常情况。
二. 多线程的实现方法
1. 继承Thread类
- 通过创建一个继承自
Thread
类的子类,并重写其run()
方法来实现线程的逻辑。 - 创建该子类的实例,并调用
start()
方法来启动线程。
class ExtendThread extends Thread {
private String name;
public ExtendThread(String name) {
this.name = name;
}
@Override
public void run() { //重写run方法
for (int i = 0; i < 5; i++) {
System.out.println(name + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//实现
public class Example {
public static void main(String[] args) {
ExtendThread t1 = new ExtendThread("线程1");
ExtendThread t2 = new ExtendThread("线程2");
t1.start();
t2.start();
}
}
2. 实现Ranable接口
- 创建一个实现
Runnable
接口的类,并实现其run()
方法。 - 将该类的实例作为参数传递给
Thread
类的构造函数,然后调用start()
方法来启动线程。
class MyRunnable implements Runnable {
private String name;
public MyRunnable(String name) {
this.name = name;
}
public void run() { //重写run方法
for (int i = 0; i < 5; i++) {
System.out.println(name + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Example {
public static void main(String[] args) {
MyRunnable run1 = new MyRunnable("线程1");
MyRunnable run2 = new MyRunnable("线程2");
Thread t1 = new Thread(run1);
Thread t2 = new Thread(run2);
t1.start();
t2.start();
}
}
3. Callable和Future
- 与
Runnable
接口类似,但是Callable
接口的call()
方法可以有返回值,并且可以抛出异常。 - 创建一个实现
Callable
接口的类,实现其中的call()
方法来定义线程执行的逻辑。 - 使用
ExecutorService
类的submit()
方法来提交该Callable
任务,从而创建并启动线程。
public class TestRandomNum implements Callable<Integer> {
/*
1.实现Callable接口,可以不带泛型,如果不带泛型,那么call方式的返回值就是Object类型
2.如果带泛型,那么call的返回值就是泛型对应的类型
3.从call方法看到:方法有返回值,可以抛出异常
* */
@Override
public Integer call() throws Exception {
int i = new Random().nextInt(10);
return i;
}
}
class Test{
//这是一个main方法:是程序的入口
public static void main(String[] args) {
//定义一个线程对象
TestRandomNum trn=new TestRandomNum();
FutureTask ft=new FutureTask(trn);
Thread t=new Thread(ft);
t.start();
//获取线程得到的返回值:
Object o = null;
try {
o = ft.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println(o);
}
}
方法一和二类似,这两个创建线程的方法无法获取到线程执行的结果,而方法三不同,它可以通过Future可以异步获取Callable执行的结果的返回值。
4. 线程池的方式创建线程
线程池创建线程的方式有很多很多,在这我只列举出一个我平常开发过程中用到最多的一个。
通过ThreadPoolExecutor
创建线程池是一种在Java中管理并发任务的常用方式。以下是使用ThreadPoolExecutor
创建线程池的基本步骤和注意事项:
- 构造方法参数:
corePoolSize
:线程池中保持的最小线程数。maximumPoolSize
:线程池中允许的最大线程数。keepAliveTime
:线程空闲时的存活时间。unit
:keepAliveTime
的时间单位。workQueue
:用于保存等待执行的任务的阻塞队列。threadFactory
:用于创建新线程的工厂。handler
:拒绝策略,当线程池无法接受新任务时的处理方式。
- 创建线程池:
javaThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // 核心线程数 4, // 最大线程数 3, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new ArrayBlockingQueue<>(4), // 有界队列 new ThreadPoolExecutor.DiscardOldestPolicy() // 拒绝策略 );
- 提交任务:
使用executor.submit()
或executor.execute()
方法提交任务给线程池执行。 - 关闭线程池:
通过调用executor.shutdown()
方法关闭线程池,这将等待所有已提交的任务完成后再关闭。
三. 线程的生命周期
1、新建状态(new):新建一个线程对象
2、就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行的线程池中,变得可运行,等待获取CPU的使用权
3、运行状态(Running):就绪状态的线程获取了CPU,执行程序代码
4、阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态
5、死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
四. 控制线程的方法
sleep()
方法:- 使当前线程暂停执行指定的时间,并交出CPU执行权。
- 不会释放对象锁,其他线程无法访问该线程持有的同步资源。
- 时间结束后,线程自动恢复到可运行状态,但不一定立即执行,需要等待CPU调度。
- 可能会抛出
InterruptedException
,如果线程在睡眠过程中被中断。
join()
方法:- 当前线程等待调用
join()
方法的线程执行完毕后再继续执行。 - 可以指定等待时间,如果超过指定时间,当前线程将继续执行。
- 如果不指定时间,当前线程将一直等待,直到调用
join()
方法的线程结束。 - 同样可能抛出
InterruptedException
。
- 当前线程等待调用
yield()
方法:- 暂停当前正在执行的线程,让同等优先级或更高优先级的线程获得执行机会。
- 不会释放对象锁,与
sleep()
类似,但不会阻塞线程,而是让线程重回就绪状态。 - 使用
yield()
后,线程需要与其他线程重新争夺CPU资源。
interrupt()
方法:- 中断线程,可以打断处于等待状态的线程,如在
wait()
或sleep()
中的线程。 - 如果线程在等待过程中被中断,会抛出
InterruptedException
。 - 对非等待状态的线程调用
interrupt()
不会抛异常,需要手动检测线程状态并做相应处理。
- 中断线程,可以打断处于等待状态的线程,如在
synchronized
关键字:- 用于实现线程同步,确保同一时刻只有一个线程可以访问被
synchronized
修饰的方法或代码块。 - 当一个线程进入同步块或方法时,它会获得对象的锁,其他线程必须等待锁被释放才能进入。
- 释放锁的情况包括:线程执行完同步代码块、线程在同步代码块中调用
wait()
方法、线程出现未处理的异常或错误。
- 用于实现线程同步,确保同一时刻只有一个线程可以访问被
wait()
、notify()
和notifyAll()
方法:- 这些方法属于
Object
类,用于线程间的通信和协作。 wait()
使当前线程等待,直到其他线程调用该对象的notify()
或notifyAll()
方法。notify()
随机唤醒一个等待该对象锁的线程,而notifyAll()
唤醒所有等待的线程。- 这些方法必须在
synchronized
代码块或方法中调用,因为它们涉及到对象锁的操作。 - 如果线程在等待过程中被中断,会抛出
InterruptedException
。1234567
- 这些方法属于
这些方法在多线程编程中起到关键作用,合理使用它们可以有效地控制线程的执行流程,实现线程间的协作和同步。
五. 线程同步
1. 概念
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其他线程也不能调用这个方法。
为什么要线程同步
在项目使用场景中,常常会出现两个或者两个以上线程同时操作数据的情况,在这种高并发场景下,共享的数据往往是不安全的。
例如:一个热点商品超卖问题,当一个商品还剩最后一个的情况下,有一个线程查询数据库发现该商品的库存为1,此时执行扣减库存操作,在查询库存和扣减库存这段时间内,任何一个线程进入查询库存操作的查询结果都是1,此时再去扣减库存就出现了超卖问题。
2. 同步互斥访问(synchronized)
基本上所有解决线程安全问题的方式都是采用“序列化临界资源访问”的方式,即在同一时刻只有一个线程操作临界资源,操作完了才能让其他线程进行操作,也称同步互斥访问。
在Java中一般采用synchronized和Lock来实现同步互斥访问。
3.synchronized关键字
在单体架构中,被synchronized修饰对象的方法或者代码块,当某个线程访问这个对synchronized方法或者代码块时,就获取到了这个对象的锁,这个时候其他对象是不能访问的,只能等待获取到锁的这个线程执行完该方法或者代码块之后,才能执行对象的方法。
1. synchronized使用范围
修饰代码块(synchronized(对象),也叫对象锁),被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;此时,同一对象的代码块竞争锁。
修饰方法(在方法前加synchronized,也叫方法锁),被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象; 锁是当前实例对象。此时,同一对象同一方法需要竞争锁,等价于在方法内部调用synchronized(this)。
修饰静态方法(在静态方法前加synchronized,也叫类锁),其作用的范围是整个静态方法,作用的对象是这个类的所有对象; 锁是当前类的Class类对象。
修饰类(synchronized(静态对象),也叫类锁),其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象。
2.底层原理
synchronized实现锁的基础就是Java对象头,synchronized锁会将线程ID存入mark word(对象头由标记字)。关于mark word,先简要了解一下Java对象。
在Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。synchronized主要是跟对象头有关系,在对象头中包含了标记字(mark word)、类指针(klass word)和 数组长度(array length)。也就是通过mark word的字节位数来表示各种锁状态。
3. synchronized锁升级
● synchronized锁在线程第一次访问的时候,实际上是没有加锁的,只是在mark word中记录了线程ID,默认也就是使用偏向锁。
● 当第二个线程来争用的时候,此时第二个线程会占用cpu,循环等待锁的释放,这时候偏向锁也就升级为自旋锁。
● 当自旋10次之后,就会升级为重量级锁,重量级锁是不占用cpu,他是使用OS的。
当线程数较少、运行时间较短的时候是比较适合使用自旋锁,反之则比较适合重量级锁。
4. synchronized与lock区别
- 存在层次:
- synchronized 是 Java 语言内置的关键字,属于 JVM 层面的锁。
- Lock 是 Java 类库提供的一个接口,属于 Java 语言层面的锁。
- 锁的获取与释放:
- synchronized 的获取和释放是隐式的,即在进入同步代码块或方法时自动获取锁,并在退出时自动释放锁。
- Lock 的获取和释放需要手动调用 lock() 方法获取锁,并在使用完后手动调用 unlock() 方法释放锁。
- 锁的释放(死锁产生):
- synchronized 在发生异常时候会自动释放占有的锁,因此不会出现死锁。
- Lock 在发生异常时候,不会主动释放占有的锁,必须手动 unlock 来释放锁,可能引起死锁的发生。
- 锁的状态:
- synchronized 无法判断锁的状态。
- Lock 可以判断锁的状态。
- 锁的类型:
- synchronized 只有一种类型的锁,即互斥锁,它是非公平锁。
- Lock 提供了多种类型的锁,包括公平锁和非公平锁。
- 性能:
- synchronized 是 JVM 内置的锁,效率相对较低,因为它会涉及到用户态和内核态的切换。
- Lock 是 Java 类库提供的锁,性能较高,因为它使用了更底层的硬件级别的实现。
- 支持锁的场景:
- synchronized 只支持在代码块和方法上加锁。
- Lock 支持更灵活的加锁和释放方式,例如可以在任意位置加锁和释放锁,支持多个条件变量的使用。
- 可重入性:
- synchronized 是可重入锁,即同一线程可以多次获取同一把锁而不会死锁。
- Lock 也是可重入锁,但需要注意要手动调用相同次数的 unlock() 方法才能完全释放锁。
- 等待通知机制:
- synchronized 使用的是 wait() 和 notify()/notifyAll() 方法实现线程之间的等待和通知机制。
- Lock 使用的是 Condition 对象来实现类似的功能。
- 可见性:
- synchronized 在进入同步代码块时会自动获取锁并刷新线程的工作内存,保证了线程间的可见性。
- Lock 需要手动使用 volatile 关键字或者显式调用 lock() 和 unlock() 方法来保证可见性。
- 锁的粒度:
- synchronized 是对整个对象进行加锁的,即当一个线程获得了某个对象的锁后,其他线程无法获得该对象的任何锁。
- Lock 可以实现更细粒度的锁定,例如可以对对象的某个属性或者某一段代码块进行加锁,从而提高并发性能。
六. 多线程使用场景
多线程使用场景有很多,在日常开发中发布邮件,信息统计,记录日志,需要执行耗时操作且不阻塞用户界面的各种场景中,都有多线程的影子。