线程概念
为什么要使用多线程
现代架构的cpu一般都是多核的,使用多个线程可以充分利用cpu多核处理的特点,来并发执行多个任务以提高任务执行的效率。
线程,进程,协程
线程是一个基本的任务调度单位,而进程是一个基本的资源调度单位,在实际的生产环境中一个进程通常含有多个线程,被包含的线程可以共同享有该进程分配到的资源,协程可以看作是一个线程里的特殊函数,这个函数可以保存和恢复上下文信息,同时其调度逻辑是由程序员来决定的。
什么是线程切换上下文
当一个线程执行完毕或者被阻塞,操作系统会将其寄存器状态,栈数据等保存下来,同时启动另一个线程,加载这个线程之前被保存的数据,这个过程就是线程切换上下文,可以看到在线程切换上下文的时候有存储又有读取,这个过程会耗费一定的时间。
并发和并行
- 并发强调的是任务或线程之间的交错执行,使得多个任务看起来像是同时发生的。
- 并行强调的是任务或线程的实际同时执行,通常需要多个处理器核心的支持。
线程操作
创建
继承Thread类
public class MyThread extends Thread{
@Override
public void run() {
System.out.println("hello thread");
}
}
public class Main {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
}
实现Runnable接口
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println("hello thread");
}
}
public class Main {
public static void main(String[] args) {
MyThread task = new MyThread();
Thread t = new Thread(task);
t.start();
}
}
实现Callable接口
public class MyThread implements Callable<String> {
@Override
public String call() throws Exception {
return "hello thread";
}
}
public class Main {
public static void main(String[] args) {
MyThread task = new MyThread();
FutureTask<String> t = new FutureTask<>(task);
Thread thread = new Thread(t);
thread.start();
}
}
除了以上三种,还有lambda表达式的创建方法,不过万变不离其一的是创建线程是通过构造一个thread类来实现的。
停止线程
停止线程正确的方式不是立即停止而是通知后停止。
使用interrupt来通知停止然后设置一个判断线程是否被停止的循环:
public class Main {
public static void main(String[] args) {
Thread thread2 = new Thread(()->{
int n=10000;
while(!Thread.currentThread().isInterrupted()&&n!=0){
System.out.println(Thread.currentThread().getName() + ":" + n);
n--;
try {
Thread.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread2.start();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
thread2.interrupt();
}
}
这里判读线程是否被停止有两种方式一种是使用Thread.interrupted,另一种是使用Thread.currentThread.isInterrupted,前者会响应通知并清除中断状态,值得注意的是使用这种方式实现的中断可以被阻塞中和睡眠中的线程响应并处理异常,这里只要在处理异常时设置状态为中断就能实现中断线程了,不过有一个误区就是使用volatile来实现中断,比如下面这种情况:
public class Main {
static volatile boolean interrupt=false;
public static void main(String[] args) {
Thread thread2 = new Thread(()->{
int n=10000;
while(!interrupt&&n!=0){
System.out.println(Thread.currentThread().getName() + ":" + n);
n--;
try {
Thread.sleep(3000000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread2.start();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
interrupt=true;
}
}
当线程被阻塞或者陷入睡眠的时候就会一直响应不到中断。
线程的六种状态
- NEW (新建): 线程对象被创建但尚未启动。
- RUNNABLE (可运行): 线程已启动,正在运行或准备好运行。
- BLOCKED (阻塞): 线程等待监视器锁以便进入同步块或方法。
- WAITING (等待): 线程等待其他线程执行特定的动作。
- TIMED_WAITING (限时等待): 线程等待其他线程执行特定的动作,并设置了等待时间。
- TERMINATED (终止): 线程已经完成执行或被提前终止。
状态之间的转换:
-
NEW → RUNNABLE: 当调用线程的
start()
方法时,线程从新建状态转变为可运行状态。此时线程还没有开始执行,只是准备就绪,等待操作系统调度。 -
RUNNABLE → BLOCKED: 当线程试图获取一个被其他线程持有的锁时,它将进入阻塞状态,直到获得锁。
-
RUNNABLE → WAITING / TIMED_WAITING: 线程调用
Object.wait()
,Thread.join()
,Thread.sleep()
或其他等待方法时,会从可运行状态转变为等待状态或限时等待状态。 -
WAITING / TIMED_WAITING → RUNNABLE: 当等待条件满足(如其他线程调用
notify()
或notifyAll()
方法,或等待时间到期),线程将从等待状态或限时等待状态转变为可运行状态。 -
RUNNABLE → TERMINATED: 当线程的
run()
方法执行完毕,或线程被显式终止(例如通过Thread.stop()
,虽然这种方法已被废弃),线程将从可运行状态转变为终止状态。
线程通信
使用wait和notify的注意事项
在使用wait和notify关键词的时候必须要使用synchronized锁,假设一个场景:当一个线程满足循环条件进入了循环,要执行wait时被一个调度线程停止了,而但这个线程再次被启动后,notify也被执行,可是由于此时这个线程并没有wait,所以不会响应notify而是执行wait(),这样一来,本来要跳出循环的线程并没有跳出而是被执行等待了。
public void put(String data) {
buffer.add(data);
notify();
}
public String take() throws InterruptedException {
while (buffer.isEmpty()) {
wait();
}
return buffer.remove();
}
所以在使用wait和notify时一定要使用synchronized来锁住操作对象:
public void put(String data) {
synchronized (this) {
buffer.add(data);
notify();
}
}
public String take() throws InterruptedException {
synchronized (this) {
while (buffer.isEmpty()) {
wait();
}
return buffer.remove();
}
}
wait和sleep方法的对比
wait和sleep都可以让线程进入等待状态,都能响应中断,不同的是wait需要配合synchronized来使用,而sleep没有这个要求,wait被唤醒后会主动释放monitor锁,wait一般是等待notify唤醒,而sleep是等待时间唤醒。
生产者消费者模式
生产者消费者模式是一种多线程编程模式,其中生产者线程负责生成数据并将数据放入共享数据结构(如阻塞队列),而消费者线程则从该数据结构中取出数据进行处理,通过这种方式解耦生产与消费的过程,同时利用同步机制保证线程安全和数据一致性。
使用阻塞队列实现:
public class Main {
static volatile boolean interrupt=false;
public static void main(String[] args) {
BlockingQueue<String> queue=new ArrayBlockingQueue<>(1);
Runnable producer=()->{
while(true){
queue.offer("hello");
System.out.println("生产者生产了");
}
};
Runnable consumer=()->{
while(true){
queue.poll();
System.out.println("消费者获取到了"+queue.poll());
}
};
Thread thread2 = new Thread(producer);
Thread thread3 = new Thread(consumer);
thread3.start();
thread2.start();
}
}
使用condition实现:
public class MyBlockingQueue<T> {
private List<T>queue=new ArrayList<T>();
private int maxSize=10;
private ReentrantLock lock=new ReentrantLock();
private Condition notEmpty=lock.newCondition();
private Condition notFull=lock.newCondition();
public synchronized void put(T t){
lock.lock();
try{
while(queue.size()>maxSize){
notFull.await();
}
queue.add(t);
notEmpty.signal();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
public T take() {
lock.lock();
try{
while(queue.size()==0){
notEmpty.await();
}
T t=queue.remove(0);
notFull.signal();
return t;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
使用wait和notify
public class MyBlockingQueue<T> {
private List<T>queue=new ArrayList<T>();
private int maxSize=10;
private ReentrantLock lock=new ReentrantLock();
private Condition notEmpty=lock.newCondition();
private Condition notFull=lock.newCondition();
public void put(T t){
while(queue.size()>maxSize){
try {
notFull.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.add(t);
notEmpty.signal();
}
public T take() throws InterruptedException {
while(queue.size()==0){
notEmpty.await();
}
T t=queue.remove(0);
notFull.signal();
return t;
}
}
并发安全
常见的并发安全问题主要是由于缺乏以下三个特性所导致:原子性,可见性,有序性。
原子性
原子性指的是一个操作或一组操作要么就完整的执行,要么就不执行,这些操作被视为一个不可分割的完整单元,在执行中不会被其它操作打断。
在高度并发的环境下,如果原子性不能得到保证,就可能出现一个线程正在执行某个操作的过程中被其他线程打断,从而导致数据状态的不可预测或不一致。这种情况通常被称为竞态条件。
比如以下的情况,当一个线程执行完i++后另一个线程抢到了cpu资源后又执行了i++,本来i是不会变的,可结果却变了。
i++;
i--;
可见性
可见性指的是一个线程对共享数据的修改能够被另一个线程及时看到。通常每个线程有自己的工作内存,在这个内存中缓存了共享变量的副本。如果没有适当的同步机制来保证可见性,就可能出现前一个线程执行完的修改,后一个线程由于并未看到这些修改而重复执行或者使用了旧的数据。
有序性
通常我们希望程序的执行是按照顺序来执行的,但编译器在编译的时候为了提高性能,可能会让后面的操作在前面先执行。这种操作重排序在单线程环境下通常不会引起问题,但在多线程环境下容易导致数据不一致。
比如一个线程执行完数据更改后要将其写入内存中,这个时候因为指令重排,另一个线程可能在数据更改写入内存之前执行了读的操作,此时这个线程读到的数据就是旧数据。
解决方案
-
synchronized
可以用来锁住一个实例方法、静态方法或代码块,以实现原子性、可见性和有序性。这确保了在多线程环境中对共享资源的访问是互斥的,并且修改后的数据能够被其他线程看到。 -
volatile
关键字修饰的变量不会被编译器或处理器重排序,同时,当一个线程修改了volatile
变量时,其他线程也能够看到最新的值。这是因为volatile
变量的读写操作具有内存屏障效果,确保了变量的可见性和有序性。 -
happens-before原则确保了前一个线程的操作能够被后面的线程所看到。在Java中,
volatile
变量的写操作发生在读操作之前,这就是一个典型的happens-before保证的例子。