实验报告八 Java多线程机制
一、实验目的及要求
-
实验目的:学习使用Thread子类或者Runnable接口创建多线程,实现线程同步机制。
-
实验要求:两个或者多个线程同时访问同一个变量时,不允许发生数据读取错误,产生数据不一致的情形,要求对实验中出现的数据问题进行分析,确定调试步骤和测试方法,对实验结果进行总结。
-
上机实验内容:实验中,编写多线程程序,模拟两个或者多个线程同时访问同一个变量时,并且一个线程需要修改这个变量,需要对这样的问题进行处理,把修改数据的方法用关键字synchronized修饰,否则可能发生数据错误,从而实现线程同步目的,完成实验报告。
二、实验环境
1.硬件要求:计算机一台
2.软件要求:Windows操作系统,使用Java语言,集成开发环境不限,建议使用如Eclipse、MyEclipse或IntelliJ IDEA等。
三、实验内容
- 线程简介
1.1 程序
程序是指令和数据的集合,其本身没有任何运行的含义,是一个静态的概念。
1.2 进程
在一个操作系统中,每个独立的程序都可以称为一个进程,也就是“正在运行的程序”,(进程就是程序执行的过程)。它是一个动态的概念,是系统分配资源的单位。
1.3 线程
每个运行的程序都是一个进程,在一个进程中还可以有多个执行单元同时运行,这些运执行单元可以看作程序执行的一 条路径,被称为线程。线程是CPU调度和执行的单位。操作系统中的每一个进程中都至少存在一个线程。当一个Java程序启动时就会产生一个进程, 该进程会默认创建一个线程, 在这个线程上会运行main ()方法中的代码。
1.4 多线程
在一个进程中,同时运行了多个线程,用来完成不同的工作,则称之为多线程。多个线程交替占用CPU资源,而非真正的并行执行。
好处:
- 充分利用CPU资源。
- 简化编程模型。
- 带来良好的用户体验。
注意:很多多线程是模拟出来的,真正的多线程是指有多个cpu,即多核,如服务器。如果是模拟出来的多线程,即在一个cpu的情况下,在同一个时间点,cpu只能执行一个代码,因为切换的很快,所以就有同时执行的错觉。
- 线程与进程的区别
-
根本区别:进程是操作系统资源分配的最小单位,而线程是处理任务调度和执行的最小单位。
-
资源开销:每个进程都有单独的代码和数据空间,进程之间的切换会有较大的开销;线程可以开做轻量级的进程,同一类线程共享代码和数据空间,每个线程有自己独立的运行栈和程序计数器,线程之间的切换开销较小。
-
包含关系:一个进程可以包含多个线程,这些线程可以共享同步资源。
-
内存分配:同一个进程中的所有线程共享本进程的地址空间和资源,而进程之间的地址空间和资源相互独立。
-
影响关系:一个进程崩溃后,不会影响其他进程;而一个线程崩溃后其他线程也会收到影响,从而整个进程崩溃。
-
执行过程:每个独立的进程都有程序运行的入口,顺序执行序列和出口,但线程不能独立执行,必须依存于进程。
- 并发、串行、并行
3.1 并发、串行、并行概念
-
并发:指多个任务在同一时间段同一个CPU上交替执行,看起来好像是同时执行的。例如,多个线程在同一时间内运行。
-
串行:指多个任务按照顺序依次执行,一个任务完成后才能执行下一个任务。例如,单线程程序就是串行执行的。
-
并行:多个处理器或多核处理器同时处理多个任务,必须需要有多个处理器或者多核 CPU 才能实现,否则只能是并发。例如,多个线程在不同的处理器或者 CPU 上运行。
3.2 并发、串行、并行的区别
-
执行方式:并发和串行都在单个处理器上执行,但是并发是多个任务交替执行,串行是按照顺序依次执行;并行需要多个处理器或多核 CPU 才能实现。
-
性能:并发和并行都可以提高程序的性能和响应速度,但是并发需要考虑线程安全和死锁等问题;串行虽然简单稳定,但是无法充分利用多核 CPU 的优势。
-
实现方式:并发可以使用多线程技术实现;串行只能使用单线程实现;并行需要多个处理器或多核 CPU 才能实现。
3.3 并发编程的三要素
-
原子性:指一个或多个操作要么全部执行成功,要么全部执行失败。
-
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。
-
有序性:程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)
3.4 多线程和并发
-
多线程是指在同一时间内运行多个线程来完成多个任务。多线程可以提高程序的性能和响应速度,因为它们可以同时执行多个任务。
-
并发是指在同一时间内执行多个任务的能力。并发可以通过使用多线程来实现,但也可以通过其他方式实现,例如使用异步编程或事件驱动编程。
- 多线程的实现方式
4.1 继承Thread类的方式进行实现
-
自己手动定义一个类继承Thread类。
-
重写里面run方法。
-
创建子类对象,并启动线程。
4.2 实现Runnable接口的方式进行实现
-
自己手动定义一个类去实现Runnable接口。
-
重写里面的run方法。
-
创建自己的类的对象。
-
创建一个Thread类的对象,并开启线程。
4.3 利用Callable接口和Future接口的方式进行实现
-
创建一个类MyCallable实现Callable接口。
-
重写里面的call方法。( 返回值表示多线程运行结果 )。
-
创建MyCallable的对象。( 表示多线程要执行的任务 )。
-
创建FutureTask的对象。( 作用管理多线程运行的结果 )。
-
创建Thread类的对象,并启动线程。( 表示线程 ) 。
4.4 三种方式比较
图1 三种多线程实现方式的比较
- 线程的生命周期
图2 线程的生命周期转化图
5.1 新建状态(New):新创建了一个线程对象。
5.2 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
5.3 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
5.4 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
(一)、等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)。
(二)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
(三)、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)
5.5 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
- 多线程常用方法
图3 多线程的常用方法
6.1 sleep()和wait()与区别
两者都可暂停当前线程
-
所在类不同:sleep()时Thread类的静态方法,wait()是Object类的方法。
-
释放锁:sleep()不会释放锁,wait()会释放锁。
-
用途不同:wait()通常用于线程间通信,sleep()通常用于暂停线程。
-
结果不同:sleep()方法执行完成后,线程会再次进入就绪态;wait()方法被notify()唤醒后,线程会进入同步队列重新抢占锁。
6.2 如何停止一个处在运行态的线程
-
该线程的run()方法执行结束后线程自动终止。
-
使用stop()方法强制终止,但一般很少这么用。
-
使用interrupt()方法中断线程(其流程为,设置一个中断标志位,调用interrupt()方法通知系统请求关闭线程,待系统在适合的时间自行中断)。
6.3 interrupt()、interrupted()、isInterrupted()方法的区别
-
interrupt()方法和isInterrupted()方法都是实例方法,通过Thread对象调用。
-
interrupted()则是静态方法,通过Thread类调用,三个方法都是与线程中断有关的。
-
interrupt()方法:用来设置中断标志位,通知系统请求结束线程,由系统决定具体何时中断。
- 多线程引发问题
多线程编程能够提高程序的性能和响应能力,但同时也会带来一些问题。主要包括以下几个方面:
-
竞态条件(Race Condition):多个线程同时访问共享资源时,由于执行顺序的不确定性,可能导致数据的不一致或错误的结果。可以使用同步机制(如锁、原子操作)来避免竞态条件。
-
死锁(Deadlock):当多个线程相互等待对方持有的资源时,可能导致死锁,使得线程无法继续执行。避免死锁的方法包括避免循环等待和按顺序获取资源。
-
饥饿(Starvation):某些线程由于无法获取所需的资源而无法执行,导致饥饿问题。为避免饥饿,可以使用公平锁、调整线程优先级等方法。
-
上下文切换(Context Switching):在多线程环境下,操作系统需要进行线程之间的切换,这会带来一定的开销。当线程数量过多时,频繁的上下文切换可能会降低程序性能。
-
线程安全性(Thread Safety):多线程程序需要保证共享资源的安全访问,避免数据竞争和不一致的结果。可以使用同步机制或线程安全的数据结构来实现线程安全。
-
性能调优(Performance Tuning):多线程程序需要考虑性能优化,包括合理设计线程池大小、减少线程间的竞争、避免不必要的锁、使用高效的数据结构等。
- 解决多线程编程中常见问题和优化性能的工具和机制
8.1 锁机制
-
定义:锁机制是解决多线程之间互斥访问共享资源的一种方式。
-
实现方式:使用 synchronized 关键字、ReentrantLock 类或 ReadWriteLock 接口等实现锁机制。
-
常用工具:Lock、Condition、Semaphore、ReadWriteLock 等。
8.2 线程池
-
定义:线程池是管理和调度多个线程的一种机制,可以避免频繁创建和销毁线程带来的性能开销。
-
实现方式:使用 Executors 类或 ThreadPoolExecutor 类创建和管理线程池。
-
常用参数:核心线程数、最大线程数、任务队列、拒绝策略等。
8.3 并发工具类
-
定义:并发工具类是解决并发编程中常见问题的一种工具,例如阻塞队列、计数器、信号量等。
-
实现方式:使用 Java.util.concurrent 包中提供的工具类实现并发编程。
-
常用工具:ArrayBlockingQueue、CyclicBarrier、Semaphore、CountDownLatch 等。
- 线程的安全问题
线程安全问题是指,多个线程对同一个共享数据进行操作时,线程没来得及更新共享数据,从而导致另外线程没得到最新的数据,从而产生线程安全问题。
解决线程的安全问题方法。
9.1 方式一:同步代码块
使用同步监视器(锁)
Synchronized(同步监视器){
//需要被同步的代码
}
9.2 方式二:同步方法
-
使用同步方法,对方法进行synchronized关键字修饰。
-
将同步代码块提取出来成为一个方法,用synchronized关键字修饰此方法。
-
对于runnable接口实现多线程,只需要将同步方法用synchronized修饰。
-
而对于继承自Thread方式,需要将同步方法用static和synchronized修饰,因为对象不唯一(锁不唯一)。
9.3 方式三:JDK5.0新增的Lock锁方法
- 创建一个Lock实例,例如ReentrantLock类。
Lock lock = new ReentrantLock();
- 在需要同步的代码块中,使用lock()方法获取锁。
lock.lock();
try {
// 需要被同步的代码
} finally {
// 最后一定要在finally块中释放锁,以确保锁的释放
lock.unlock();
}
- 在finally块中使用unlock()方法释放锁,以确保锁的释放。
- java中线程的调度策略
10.1 java虚拟机采用抢占式调度模型,值让优先级高的线程先分配CPU时间片,对于优先级相同的线程,采用随机分配,处于运行态的线程会一直执行,直到执行结束或被终止。
10.2 终止线程的几种情况
-
线程调用yield()方法让出对CPU的占用
-
线程调用sleep()方法时线程进入睡眠状态
-
线程由于IO操作被迫阻塞等待
-
另一个更高优先级的线程进入就绪态
-
CPU一次分配的时间片执行完
- 实验案例:Java编写生产者-消费者模型
要求: 设计一个Java多线程的生产者-消费者模型,要求使用synchronized、wait、notify或notifyAll等关键字来实现线程同步和协作机制,确保线程安全和避免潜在的死锁情况。,具体要求如下:
-
定义一个共享资源类 Buffer,内部包含一个整型数组作为缓冲区,以及相应的读取和写入方法。
-
定义一个生产者类 Producer,继承自 Thread,具有一个 Buffer 对象作为成员变量。
-
定义一个消费者类 Consumer,继承自 Thread,具有一个 Buffer 对象作为成员变量。
-
在 Producer 类中,重写 run() 方法,实现生产者线程的逻辑,即不断向缓冲区中写入随机生成的整数。
-
在 Consumer 类中,重写 run() 方法,实现消费者线程的逻辑,即不断从缓冲区中读取整数并打印。
-
在主函数中创建一个 Buffer 对象以及多个生产者线程和消费者线程,并启动这些线程。
-
缓冲区大小为10,即最多可以存储10个整数。
-
当缓冲区已满时,生产者线程需要等待; 当缓冲区为空时,消费者线程需要等待; 同时,生产者线程写入数据后需要通知消费者线程,消费者线程读取数据后需要通知生产者线程。
11.1 实验步骤:Java生产者-消费者模型实现
-
创建共享资源类 Buffer
-
定义一个带有缓冲区的类,包含读取和写入方法。使用 synchronized 关键字确保方法的同步访问,并利用 wait() 和 notifyAll() 方法实现线程等待和唤醒机制。
-
创建生产者类 Producer
编写一个生产者类,继承自 Thread,负责向缓冲区不断写入数据。在 run() 方法中实现逻辑,例如生成随机整数并调用 Buffer 的写入方法。
- 创建消费者类 Consumer
编写一个消费者类,继承自 Thread,负责从缓冲区不断读取数据并进行处理。在 run() 方法中实现逻辑,调用 Buffer 的读取方法获取数据。
- 主函数中创建并运行生产者和消费者线程
在主函数中创建 Buffer 对象以及多个生产者和消费者线程。启动这些线程并观察它们之间的交互过程,确保线程安全并避免数据竞争和死锁等问题的发生。
- 观察交互过程
运行主函数,观察生产者和消费者线程之间的交互过程。注意生产者线程向缓冲区写入数据时,消费者线程会被唤醒来消费数据,反之亦然。确保缓冲区的大小限制得到遵守,即不会出现数据溢出或缺失的情况。
- 调整参数和观察结果
尝试调整生产者和消费者线程的数量,以及休眠时间等参数,观察对交互过程的影响。可以通过修改缓冲区大小和其他参数来测试程序的鲁棒性和性能。
- 分析可能的问题
考虑可能出现的问题,例如死锁、数据竞争、线程安全性等,并思考如何改进代码以解决这些问题。使用 synchronized、wait()、notifyAll() 等关键字时,要确保正确的使用方式,以避免潜在的问题。
- 总结和思考
总结实验过程中遇到的问题以及解决方法。
11.2 UML图设计
图4 展示生产者线程、消费者线程和资源池之间的交互过程的时序图
图5 展示生产者和消费者线程之间的数据流动的数据流程图
##四、实验结果与分析
- 实验结果
图6 实验结果
- 实验结果分析
-
生产者和消费者线程交替执行: 输出结果显示了生产者线程和消费者线程在资源池不为空的情况下交替执行。生产者线程生产资源后,资源池数量增加;消费者线程消耗资源后,资源池数量减少。
-
等待和唤醒机制: 当资源池满时,生产者线程进入等待状态,等待消费者线程消耗资源后通过notifyAll()唤醒。当资源池为空时,消费者线程进入等待状态,等待生产者线程生产资源后通过notifyAll()唤醒。这种机制确保了线程的协调和同步。
-
线程安全性: 使用synchronized关键字确保了对资源池的访问是线程安全的。生产者和消费者之间通过互斥访问资源池,避免了竞态条件和数据不一致的问题。
- 实验中的问题及解决方法
- 问题描述: 如果在代码中存在潜在的死锁条件,可能会导致程序在运行时停滞。
解决方法: 确保正确使用wait()和notifyAll(),并仔细检查代码,以确保没有导致死锁的条件。
- 问题描述: 多个线程竞争资源可能导致不确定的行为。
解决方法: 使用synchronized关键字来确保对共享资源的访问是同步的,避免竞争条件。
- 问题描述: 没有正确地保护共享资源,可能导致数据不一致或其他线程安全问题。
解决方法: 使用synchronized来确保对共享资源的操作是原子的,或者考虑使用java.util.concurrent包中的更高级的并发工具。
- 问题描述: 某个线程可能一直处于等待状态,无法被唤醒。
解决方法: 确保正确使用wait()和notifyAll(),并检查是否有逻辑错误导致某个线程一直处于等待。
- 问题描述: 当资源池满时,生产者线程可能进入死锁状态,或者消费者线程可能一直等待。
解决方法: 在生产者等待时,确保有机制唤醒它;在消费者等待时,也确保有机制唤醒它。这通常通过使用notifyAll()来实现。
- 实验不足
-
代码中未提供充分的异常处理机制,可能导致程序在发生异常时不稳定。
-
生产者和消费者线程都使用固定的等待时间,这可能导致在某些情况下线程等待时间太长或太短。
-
缺乏详细的日志记录,使得在调试或分析问题时难以理解程序的运行情况。
-
代码中未使用Java并发工具包中的更高级工具,如ExecutorService、BlockingQueue等。
-
生产者和消费者线程的创建和管理是手动完成的,可能在实际应用中不够灵活。
-
没有进行充分的性能测试,无法评估系统在高并发情况下的性能表现。
-
只有一个资源池可能限制了系统的扩展性,特别是在多个资源类型存在的情况下。
- 实验心得
-
在生产者-消费者模型中,正确管理共享资源是一个挑战。需要确保资源的正确生产和消费,避免资源泄漏和竞争条件。
-
使用适当的同步机制,如synchronized关键字、wait()和notifyAll()方法,可以确保线程间的正确协作和资源的安全访问。
-
在多线程环境下,异常处理尤为重要。合理处理异常可以避免程序崩溃,并提供更好的错误处理和恢复机制。
-
合理的线程数量、资源池大小和同步机制选择可以提高系统的性能和可扩展性。
-
添加适当的日志输出可以帮助理解程序的执行过程和状态变化。
-
实验过程中可能会发现一些实验不足,如异常处理不完善、性能测试不充分等。这些发现可以帮助我们改进代码和系统设计,提高系统的质量和可靠性。
-
多线程编程是一个复杂的领域,需要不断学习和改进。通过实验和反思,我们可以不断提高自己的技能和知识,更好地应对多线程编程的挑战。
##五、附源程序
ProducerConsumerWithWaitNotify.java
package donbin_shiyanbaogao;
public class ProducerConsumerWithWaitNotify {
public static void main(String[] args) {
Resource resource = new Resource();
// 生产者线程
ProducerThread p1 = new ProducerThread(resource);
ProducerThread p2 = new ProducerThread(resource);
ProducerThread p3 = new ProducerThread(resource);
// 消费者线程
ConsumerThread c1 = new ConsumerThread(resource);
ConsumerThread c2 = new ConsumerThread(resource);
ConsumerThread c3 = new ConsumerThread(resource);
p1.start();
p2.start();
p3.start();
c1.start();
c2.start();
c3.start();
}
// 生产者线程
static class ProducerThread extends Thread {
private final Resource resource;
public ProducerThread(Resource resource) {
this.resource = resource;
}
@Override
public void run() {
// 不断地生产资源
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
resource.add();
}
}
}
// 消费者线程
static class ConsumerThread extends Thread {
private final Resource resource;
public ConsumerThread(Resource resource) {
this.resource = resource;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
resource.remove();
}
}
}
// 共享资源类
static class Resource {
// 当前资源数量
private int currentCount = 0;
// 消费者消耗资源的方法
public synchronized void remove() {
if (currentCount > 0) {
currentCount--;
System.out.println("--- 消费者" +
Thread.currentThread().getName() +
"消耗一件资源,当前线程池有" + currentCount + "个");
notifyAll(); // 通知生产者生产资源
} else {
try {
// 如果没有资源,则消费者进入等待状态
wait();
System.out.println("--- 消费者" +
Thread.currentThread().getName() + "线程进入等待状态");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 生产者生产资源的方法
public synchronized void add() {
// 资源池中允许存放的资源数目
int maxCount = 10;
if (currentCount < maxCount) {
currentCount++;
System.out.println("+++" +
Thread.currentThread().getName() + "生产一件资源,当前资源池有"
+ currentCount + "个");
// 通知等待的消费者
notifyAll();
} else {
// 如果当前资源池中有10件资源
try {
wait(); // 生产者进入等待状态,并释放锁
System.out.println("+++" +
Thread.currentThread().getName() + "线程进入等待");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}