目录
当今软件开发领域中,多线程编程已成为一项至关重要的技能。然而,要编写出高效、可靠的多线程程序并不容易。多线程编程面临着许多挑战,如线程安全性、资源共享、死锁等问题。因此,对于初学者来说,深入理解Java多线程的工作原理和机制是至关重要的。只有通过掌握多线程的核心概念、了解常见问题和解决方案,我们才能写出健壮且高性能的多线程应用。
本文将为大家逐步深入介绍Java多线程的重要概念和机制。我们将从线程的创建和启动开始,讨论如何使用线程池管理线程,并探讨线程间的通信和同步技术。我们还将介绍一些常用的多线程设计模式和最佳实践,帮助读者更好地应用多线程技术解决实际问题。
线程的生命周期描述了一个线程从创建到终止的整个过程,一般包含以下几个阶段:
-
新建状态(New):
- 当线程对象被创建后,它处于新建状态。
- 此时,线程还未被启动,即尚未调用start()方法。
-
可运行状态(Runnable):
- 当线程调用start()方法后,进入可运行状态。
- 线程处于此状态时,可能正在执行,也可能正在等待系统资源。
-
运行状态(Running):
- 可运行状态中的线程被系统调度执行,处于运行状态。
- 线程执行run()方法中的任务代码。
-
阻塞状态(Blocked):
- 阻塞状态指线程因为某些原因暂时停止执行,例如等待某个资源、等待锁的释放等。
- 当满足特定条件时,线程会进入阻塞状态,等待条件满足后被唤醒。
-
无限期等待状态(Waiting):
- 线程在某些条件下调用无参数的wait()方法,会进入无限期等待状态。
- 只有当其他线程显式地调用notify()或notifyAll()方法,或者被中断,才能解除该状态。
-
限期等待状态(Timed Waiting):
- 线程在某些条件下调用具有超时参数的wait()、sleep()、join()或LockSupport.parkNanos()等方法,会进入限期等待状态。
- 时间一过,或者收到特定事件的通知,该线程将会被唤醒。
-
终止状态(Terminated):
- 线程执行完run()方法中的任务代码,或者线程发生异常而提前结束,都会进入终止状态。
- 一旦线程进入终止状态,就不能再切换到其他状态。
需要注意的是,线程的状态可以相互切换,具体的转换由Java的线程调度器和操作系统决定。线程的生命周期和状态的转换对于多线程编程非常重要,合理地管理线程的状态可以提高程序的性能和并发能力。
我们用一个案例来说明:
现在我们要开设三个窗口来买票,一共有100张票,请你利用多线程的知识完成。
class MyThread extends Thread { static int tick=0; public void run() { // 定义线程要执行的任务 while(true) { if(tick<100) { try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } tick++; System.out.println(getName()+"正在卖第"+tick+"张票"); } else { break; } } }}
public class test05 { public static void main(String[] args) { MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); MyThread t3 = new MyThread(); t1.start(); t2.start(); t3.start(); }}
很多同学在第一时间就会写出这样一个简单的多线程,但是当我们运行之后,就会有一个明显的问题:出现了一张票卖了两次这种情况 ,也会出现了卖超了的这种现象。
我们来详细解释一下为什么
线程1和线程2和线程3都在抢夺cpu调度,假设线程1抢到之后,那么他先进入if语句,但if语句中有一个sleep,执行到这里后,线程1就会被阻塞睡眠,此时线程2和线程3重新抢夺cpu调度,线程2抢到资源之后进入if语句也会睡眠,然后就是线程3进入资源,也会睡眠。随着这三个的睡眠周期结束,就又会执行if中的代码。当tick还没来得及打印的时候,线程2醒来又会抢夺cpu资源,如果抢到了,就又会执行一次tick++,接下来又是线程3.如此这样循环,就会造成卖出两张票并且可能卖超的结果。
通过这个案例我们可以看出多线程在执行的时候,有一个重要的隐患:
线程的执行具有随机性
那么我们最简单的思路就是:
设计一种方法,这个方法使得 如果一个线程正在执行代码,那么其他的线程必须等待,只有当这个线程执行完之后,其他的线程才可以抢占CPU资源。这就是我们下面要介绍的东西
同步代码块:
把代码块用锁锁起来
synchronized(锁){ 操作共享数据的代码}
特点:
- 锁是默认打开的,如果有一个进程进去了,锁就会自动关闭。
- 里面的代码全部执行完毕,线程出来,锁自动打开
因此我们尝试一下用锁来改进一下
class MyThread extends Thread { static int tick=0; static Object oj = new Object(); public void run() { // 定义线程要执行的任务 while(true) { try { Thread.sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (oj) { if(tick<10000) { tick++; System.out.println(getName()+"正在卖第"+tick+"张票"); } else { break; } } } }}
锁的注意点:
-
锁的粒度:要在保证线程安全的前提下,尽量减小锁的范围。过大的锁粒度可能导致不必要的线程阻塞,影响性能。可以考虑使用细粒度锁或者使用并发集合类来提高并发性能。
-
锁的公平性:锁可以是公平的或非公平的。公平锁会按照线程请求锁的顺序依次获取锁,而非公平锁则不保证线程获取锁的先后顺序。在选择锁时,根据具体情况选择公平或非公平锁。
-
死锁情况:死锁是指两个或多个线程相互等待对方释放持有的锁,从而导致所有线程无法继续执行的情况。为避免死锁,需要谨慎设计锁的获取顺序,并尽量避免嵌套锁的情况。
-
锁的释放:在使用锁时,需要保证锁的正确释放,以免出现资源泄漏或线程饥饿等问题。一般可以使用try-finally块来确保在发生异常时仍能正确释放锁。
-
锁的性能:锁的竞争会带来一定的性能开销,过多的锁竞争可能会影响应用的并发性能。可以考虑使用读写锁、无锁数据结构或并发集合类等替代方案,来降低锁竞争带来的性能开销。
-
死锁检测和避免:一旦发生死锁,所有线程都将无法继续执行。为了避免死锁,可以使用工具进行死锁检测,并合理设计锁的获取和释放顺序,避免潜在的死锁情况。
同步方法:
把方法用 锁 锁起来
修饰符 synchronized 返回值类型 方法名 (方法参数){...}
特点:
- 同步方法是锁住方法里面的所有代码
- 锁对象不能自己指定
非静态:this
静态:当前类的字节码文件
则我们可把前面的改写为:
class MyThread extends Thread { static int tick=0; static final Object oj = new Object(); public synchronized void run() { // 定义线程要执行的任务 while(true) { if (tick < 100) { tick++; System.out.println(getName() + "正在卖第" + tick + "张票"); } else { break; } } } }
死锁:
死锁是指在多线程编程中,两个或多个线程互相持有对方需要的资源,导致它们都无法继续执行,称为死锁现象。
死锁的发生通常需要满足以下四个条件,也称为死锁的必要条件:
- 互斥条件:至少有一个资源同时只能被一个线程持有。
- 请求与保持条件:一个线程在持有某个资源的同时,又请求获取其他线程持有的资源。
- 不可剥夺条件:已经分配给一个线程的资源不能被强制性地剥夺,只能由持有该资源的线程显式释放。
- 循环等待条件:多个线程之间形成循环等待一系列资源,而每个线程都在等待下一个线程所持有的资源。
当以上四个条件都满足时,就可能出现死锁。在死锁发生时,这些线程将无法继续执行下去,需要通过一些策略进行解决,如避免死锁的产生、检测死锁、解除死锁等。
解决死锁的方法一般有以下几种:
- 避免死锁:通过破坏死锁的必要条件之一,如避免循环等待,确保资源分配的顺序性。
- 检测与恢复:通过资源分配图、银行家算法等方法检测死锁的发生,然后采取相应的策略进行恢复,如终止某些线程、回收资源等。
- 预防死锁:通过一些算法和策略在设计阶段预防死锁的发生,如资源有序分配法、资源剥夺等。
- 忽略死锁:对于一些系统来说,死锁的发生概率较低且解决代价较高,可以选择忽略死锁。当发生死锁时,通过系统重启或人工介入恢复正常。
public class DeadlockExample { public static void main(String[] args) { final Object resource1 = new Object(); final Object resource2 = new Object(); Thread thread1 = new Thread(() -> { synchronized (resource1) { System.out.println("Thread 1 acquired lock on resource1"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (resource2) { System.out.println("Thread 1 acquired lock on resource2"); } } }); Thread thread2 = new Thread(() -> { synchronized (resource2) { System.out.println("Thread 2 acquired lock on resource2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (resource1) { System.out.println("Thread 2 acquired lock on resource1"); } } }); thread1.start(); thread2.start(); // 等待两个线程执行完毕 try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Execution completed"); }}
在上述代码中,两个线程 thread1
和 thread2
分别尝试获取 resource1
和 resource2
的锁。但是它们获取锁的顺序是相反的,即 thread1
先获取 resource1
的锁,再获取 resource2
的锁;而 thread2
先获取 resource2
的锁,再获取 resource1
的锁。
这种情况下,如果两个线程同时启动,则 thread1
获取了 resource1
的锁并等待 resource2
的锁释放,而 thread2
获取了 resource2
的锁并等待 resource1
的锁释放。由于两个线程相互等待对方所持有的锁,它们将处于死锁状态,无法继续执行下去。
在Java中,生产者-消费者模式是一种常见的多线程协作模式,用于解决生产者和消费者之间的数据交换和同步问题。
我们在以前的多线程中,会发现每条线程是都执行都是随机的,可能会是
A A A A B B A A B A
而等待唤醒机制可以是线程的交替变得有规律,变为
A B A B A B A B A B A
生产者是生成数据的线程,而消费者是消耗数据的线程。下面是对Java中生产者和消费者的详细介绍:
-
生产者:
- 生产者负责生产数据,并将其放入共享的缓冲区或队列中,以供消费者使用。
- 生产者线程通常会循环执行,生成数据并将其添加到缓冲区中。
- 当缓冲区已满时,生产者会等待,直到有足够的空间来存放新的数据。
-
消费者:
- 消费者负责从缓冲区中获取数据,并进行消费或处理。
- 消费者线程通常会循环执行,从缓冲区中取出数据并进行相应的处理操作。
- 当缓冲区为空时,消费者会等待,直到有新的数据可供消费。
-
共享缓冲区:
- 生产者和消费者之间的数据交换通常通过共享的缓冲区或队列来进行。
- 缓冲区可以是一个数组、一个队列或其他数据结构,用来存放生产者生成的数据,供消费者取出。
- 缓冲区的大小是有限的,当缓冲区已满时,生产者必须等待;当缓冲区为空时,消费者必须等待。
- 生产者将数据添加到缓冲区的末尾,而消费者从缓冲区的前端消费数据。
为了实现生产者-消费者模式,可以使用以下方法之一:
-
wait() 和 notify():
- 使用对象的 wait() 和 notify() 方法来实现线程的等待和唤醒操作。
- 生产者在缓冲区已满时调用 wait() 方法进行等待,并在生产数据后调用 notify() 方法唤醒消费者。
- 消费者在缓冲区为空时调用 wait() 方法进行等待,并在消费数据后调用 notify() 方法唤醒生产者。
-
Condition 和 Lock:
- 使用
java.util.concurrent.locks.Condition
和java.util.concurrent.locks.Lock
接口来实现线程的等待和唤醒操作。 - 生产者和消费者分别使用不同的条件变量来等待和唤醒。
- 使用Lock对象来保护共享数据的访问,通过条件变量的
await()
和signal()
方法进行线程的等待和唤醒操作。
- 使用
生产者-消费者模式可以帮助解决多线程并发情况下的数据同步和数据交换问题,确保生产者和消费者之间的协调运行。这种模式在许多并发编程场景中都有应用,如线程池、消息队列、生产者-消费者问题等。
生产者与消费者模式的意义:
-
解耦生产者和消费者:
- 生产者-消费者模式将数据的生产和消费过程解耦,使得生产者和消费者可以独立进行操作。
- 生产者只需关注生成数据并将其放入缓冲区,而不需要关心数据如何被消费。
- 消费者只需关注从缓冲区中获取数据并进行相应处理,而不需要关心数据的生成过程。
-
提高系统的并发性和吞吐量:
- 生产者和消费者可以并行地执行,从而提高系统的并发性能。
- 生产者不必等待消费者完成对数据的处理,而可以继续生产新的数据。
- 消费者不必等待生产者生成新的数据,而可以并行地处理已有的数据。
-
缓冲区平衡生产和消费速度:
- 生产者和消费者之间通过共享的缓冲区进行数据交换和同步。
- 缓冲区充当了生产者和消费者之间的中介,平衡了它们之间的生产和消费速度。
- 当生产者速度快于消费者时,数据会被存储在缓冲区中,以供消费者使用。
- 当消费者速度快于生产者时,消费者可以从缓冲区中获取数据,而不必等待生产者生成。
-
实现线程间的通信和同步:
- 生产者-消费者模式为线程间的通信和同步提供了一种有效的方式。
- 生产者和消费者可以利用等待和唤醒机制来实现线程的同步和协作。
- 生产者在缓冲区已满时等待,直到有可用空间;消费者在缓冲区为空时等待,直到有可供消费的数据。
- 当生产者生成新的数据或消费者消耗了数据时,它们可以相互通知和唤醒对方。
综上所述,生产者-消费者模式是一种重要的多线程编程模式,它能够提高系统的并发性、吞吐量和效率,实现生产者和消费者之间的解耦和协作,确保数据交换和同步的正确性和可靠性。在并发编程和异步系统中广泛应用。
import java.util.LinkedList;class Producer implements Runnable { private LinkedList buffer; private int maxSize; public Producer(LinkedList buffer, int maxSize) { this.buffer = buffer; this.maxSize = maxSize; } @Override public void run() { for (int i = 1; i <= 10; i++) { try { produce(i); } catch (InterruptedException e) { e.printStackTrace(); } } } private void produce(int value) throws InterruptedException { synchronized (buffer) { while (buffer.size() == maxSize) { System.out.println("缓冲区已满,生产者等待..."); buffer.wait(); } buffer.add(value); System.out.println("生产者生产: " + value); buffer.notifyAll(); } }}class Consumer implements Runnable { private LinkedList buffer; public Consumer(LinkedList buffer) { this.buffer = buffer; } @Override public void run() { while (true) { try { consume(); } catch (InterruptedException e) { e.printStackTrace(); } } } private void consume() throws InterruptedException { synchronized (buffer) { while (buffer.size() == 0) { System.out.println("缓冲区为空,消费者等待..."); buffer.wait(); } int value = buffer.removeFirst(); System.out.println("消费者消费: " + value); buffer.notifyAll(); } }}public class ProducerConsumerExample { public static void main(String[] args) { LinkedList buffer = new LinkedList<>(); int maxSize = 5; Producer producer = new Producer(buffer, maxSize); Consumer consumer = new Consumer(buffer); Thread producerThread = new Thread(producer); Thread consumerThread = new Thread(consumer); producerThread.start(); consumerThread.start(); }}
这个示例中,生产者线程通过 produce()
方法在 buffer
中生产数据,而消费者线程通过 consume()
方法从 buffer
中消费数据。其中,buffer
是一个共享的缓冲区,采用了等待和唤醒机制来实现线程的同步。
注意,在示例中使用了 LinkedList
作为缓冲区,但这只是一种示例使用的数据结构,实际上可以使用其他线程安全的数据结构,如 ArrayBlockingQueue
或 LinkedBlockingQueue
来实现更高效的生产者-消费者模式。
运行代码示例后,你可以观察到生产者逐个生成数据并放入缓冲区,而消费者逐个从缓冲区中取出数据消费,它们之间的执行是交替进行的。当缓冲区已满时,生产者线程会等待;当缓冲区为空时,消费者线程会等待。这样,生产者和消费者之间的数据交换和同步就实现了。
今天我们学习了多线程中必要有意思的寿命周期,锁以及一个多线程的经典模式:生产者和消费者模式。多线程作为一项处理高并发和高吞吐量的重要技术,其各项知识点我们都应该拥有较好的掌握程度,这样才可以熟练的使用多线程。
如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!