计算机网络实验报告2
实验目的
- 理解多线程并熟悉Java多线程编程
- 熟悉并掌握线程创建、线程控制
- 熟悉并掌握线程同步、线程交互
实验任务
使用常用的两种方式创建线程
使用join()等方法进行线程控制
学习使用synchronized关键字进行线程同步
学习使用wait()和notify()方法进行线程交互
实验过程
创建线程
继承Thread类创建线程
通过继承Thread类来创建并启动多线程的一般步骤如下
1) 定义Thread类的子类,并重写该类的run()方法,该方法的方法体就是线程需要完成的任务,run()方法也称为线程执行体。
2) 创建Thread子类的实例,也就是创建了线程对象
3) 启动线程,即调用线程的start()方法
具体代码如下图所示:
任务1:
改写 run()
方法,将当前线程的信息打印出来
Mythread_a
类
1 | package com.company; |
Main
类
1 | package com.company; |
结果如下:
覆写Runnable()、run()
通过实现Runnable接口创建并启动线程一般步骤如下:
1) 定义Runnable接口的实现类,一样要重写run()方法,这个run()方法和Thread中的run()方法一样是线程的执行体
2 ) 创建Runnable实现类的实例,并用这个实例作为Thread的target来创建Thread对象,这个Thread对象才是真正的线程对象
3) 第三部依然是通过调用线程对象的start()方法来启动线程
任务2:
改写 run()
方法,实现循环打印1-100
Mythread_b
类:
1 | package com.company; |
Main
类:
1 | package com.company; |
打印结果如下:
线程控制
join()线程
join线程可以让一个线程等待另一个线程执行完毕以后再执行。
1)作用:主要作用是同步,它可以使得线程之间的并行执行变为串行执行。在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。
2)Join()和start()调用顺序问题:join()方法必须在线程start()方法调用之后调用才有意义。一个线程都还未开始运行,同步是不具有任何意义的。
3)原理:join方法的原理就是调用相应线程的wait方法进行等待操作的,例如A线程中调用了B线程的join方法,则相当于在A线程中调用了B线程的wait方法,当B线程执行完(或者到达等待时间),B线程会自动调用自身的notifyAll方法唤醒A线程,从而达到同步的目的。
任务3:
完善代码,用join()实现正常的逻辑
ThreadJoin
类:
1 | package com.company; |
Main
类:
这里我设计了一个 Thread集合用来存放线程。每个线程声明的时候就将其添加到线程集合当中去。然后通过循环,依次启动四个线程
1 | package com.company; |
结果如下:每隔0.5秒按顺序出现一条讯息
守护线程
java中有一种线程只在后台运行,为其他线程提供服务,这种线程就是守护线程(Daemon Thread)。
只要当前JVM实例中尚存任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作,Daemon作用是为其他线程提供便利服务,守护线程最典型的应用就是GC(垃圾回收器)。
User和Daemon两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果 User Thread已经全部退出运行了,只剩下Daemon Thread存在了,虚拟机也就退出了。 因为没有了被守护者,Daemon也就没有工作可做了,也就没有继续运行程序的必要了。
任务4:
完善代码,将该线程设置为守护线程,当主线程结束时,结束该线程.
学习资料:https://www.liaoxuefeng.com/wiki/1252599548343744/1306580788183074
如何创建守护线程呢?方法和普通线程一样,只是在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
ThreadDaemon
类:
1 | package com.company; |
Main
类:
1 | package com.company; |
结果如下:
如果不将ThreadDaemon设为守护线程的话,在输出同学们下课了之后,还会继续输出 助教在教师的第 i 秒。而将其设为守护线程之后,当主线程main结束时,守护线程也结束了,不再打印。
线程优先级
线程的优先级用1-10之间的整数表示,数值越大优先级越高,默认的优先级为5。线程的优先级仍然无法保障线程的执行次序。只不过,优先级高的线程获取CPU资源的概率较大,优先级低的并非没机会执行。
高优先级的线程比低优先级的线程有更高的几率得到执行,实际上这和操作系统及虚拟机版本相关,有可能即使设置了线程的优先级也不会产生任何作用
任务5:
完善代码,将两个线程设置为不同的优先级,并将不同的优先级的情况下的输出结果记录下来,总结优先级的特点。
当我们设置:t2为最高优先级,t1为最低优先级的时候:
1 | t2.setPriority(Thread.MAX_PRIORITY); |
前面10次中线程2执行了5次,线程1执行了5次。
当我们设置t1为最高优先级,t2位最低优先级的时候:
前面十次中线程1执行了5次,线程2也执行了5次
这说明,在我的电脑里,即使设置了线程的优先级也不会产生任何作用。
线程让步
线程让步用于正在执行的线程,在某些情况下让出CPU资源,让给其它线程执行。
yield()方法会让线程回到就绪状态,直接等到cpu重新分配资源,但只有优先级和该线程相等或大于该线程的其他线程才有机会被执行。
任务6:
完善代码,将两个线程设置为不同的优先级,并将第一个线程设置为让步状态,总结线程让步的特点。将关键代码和总结的内容写到实验报告中
wait()的作用是让当前线程由“运行状态”进入“等待(阻塞)状态”的同时,也会释放同步锁。而yield()的作用是让步,它也会让当前线程离开“运行状态”。它们的区别是:
- wait()是让线程由“运行状态”进入到“等待(阻塞)状态”,而yield()是让线程由“运行状态”进入到“就绪状态”。
- wait()是会线程释放它所持有对象的同步锁,而yield()方法不会释放锁
Mythread3:
1 | package com.company; |
然后我们启动main函数:
1 | public class Main { |
结果我们发现这并不能改变两个线程的运行顺序。这可能是因为yield()方法并不能立刻交出CPU,或者是让步后的线程还有可能被线程调度程序再次选中。
线程同步
当我们有多个线程要同时访问一个变量或对象时,如果这些线程中既有读又有写操作时,就会导致变量值或对象的状态出现混乱,从而导致程序异常。举个例子,如果一个银行账户同时被两个线程操作,一个取100块,一个存钱100块。假设账户原本有0块,如果取钱线程和存钱线程同时发生,会出现什么结果呢?取钱不成功,账户余额是100.取钱成功了,账户余额是0.那到底是哪个呢?很难说清楚。因此多线程同步就是要解决这个问题。
在多线程中,可能有多个线程试图访问一个有限的资源,必须预防这种情况的发生。所以引入了同步机制:在线程使用一个资源时为其加锁,这样其他的线程便不能访问那个资源了,直到解锁后才可以访问。
synchronized同步方法:锁定的是当前对象。当多线程通过同一个对象引用多次调用当前同步方法时,需同步执行。也就是说当一个线程访问同步方法时,其他线程访问这个方法将会被阻塞(等待锁)。
synchronized同步代码块:用关键字synchronized声明方法在某些情况下是有弊端的,比如A线程调用同步方法执行一个较长时间的任务,那么B线程必须等待比较长的时间。这种情况下可以尝试使用synchronized同步代码块来解决问题。
同步代码块的同步粒度更加细致,是商业开发中推荐的编程方式。可以定位到具体的同步位置,而不是简单的将方法整体实现同步逻辑。在效率上,相对更高。
任务7:
采用同步方法和同步代码段的方法来进行线程控制,总结对比两种方式的优缺点。将代码和总结的结果写到实验报告中。
这里我们设计了一个模拟多任务下载的软件:一共有3个类
ThreadDemo
类
1 | package com.company.concurrency; |
DownloadFileTask
类
在这个Run函数当中,我们做一个10000次的循环,每一次循环都调用status对象的 incrementTotalBytes()
函数。用来模拟下载一个 10000 bits的文件。
1 | package com.company.concurrency; |
DownloadStatus
类
在这个类中,有一个下载总比特数的私有变量,当有线程中的对象调用incrementTotalBytes()
的时候,totalBytes就会自增1
1 | package com.company.concurrency; |
在预期的情况下,我们打开了10 个线程,每个线程都会下载10_000比特的数据,那么totalBytes
的结果应该是100_000.但是我们多次运行之后,一直都是八九万,并没有到十万。这是因为发生了Race condition
,线程在互相争抢修改同一个数据的时候,会发生数据丢失。这时候我们就可以使用 synchronized 关键词
但是Java程序依靠synchronized
对线程进行同步,使用synchronized
的时候,锁住的是哪个对象非常重要。
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized
逻辑封装起来。
比如我们现在就要用synchronized来封装totalBytes++
,这是同步代码段的方法
1 | public void incrementTotalBytes(){ |
这样一来,线程调用incrementTotalBytes
方法时,它不必关心同步逻辑,因为synchronized
代码块在incrementTotalBytes
方法内部。并且,我们注意到,synchronized
锁住的对象是this
,即当前实例,这又使得创建多个DownloadStatus
实例的时候,它们之间互不影响,可以并发执行。
当我们锁住的是 this
实例的时候,实际上可以用 synchronized
来修饰这个方法,因此这两种方法是等价的:
1 | public synchronized void incrementTotalBytes(){ |
因此,用synchronized
修饰的方法就是同步方法,它表示整个方法都必须用this
实例加锁。不能对其他实例加锁。
但是,对 this
实例加锁也是有缺点的。比如说:我又新建了一个totalFiles
变量来记录已下载完成的文件总数。因为文件一多,很可能是两个文件同时下载完成的,因此我们也需要用 synchronized
关键字来修饰
那么问题来了:incrementTotalByts
和incrementTotalFiles
这两个方法都给 this
对象上了锁。那么如果存在某一个时刻,要同时调用这两个方法的时候,必须等其中一个方法运行完之后把this对象解锁了之后才可以继续执行另一个方法。如果这只是一个小型应用,也许没事;但是如果这个应用非常庞大,需要上锁的参数非常多,那么同时调用的时刻会很多,会造成不必要的等待、降低程序的性能。
为了解决这个问题,我们可以给每一个需要上锁的变量新建一个专属对象。并用这个对象传入synchronized
关键字。如下图所示:
我们创建了两个Object类型的对象,一个叫totalBytesLock
用来锁住totalBytes
; 以及totalFilesLock
用来锁住totalFiles
变量。
同步代码块能达成和同步方法一样的功能,但是效能比同步方法更高。
线程交互
线程交互是指两个线程之间通过通信联系对锁的获取与释放,从而达到较好的线程运行结果,避免引起混乱的结果。一般来说synchronized块的锁会让代码进入同步状态,即一个线程运行的同时让其它线程进行等待,那么如果需要进行实现更复杂的交互,则需要学习以下几个方法:
void notify(): 唤醒在此对象监视器上等待的单个线程。
void notifyAll(): 唤醒在此对象监视器上等待的所有线程。
void wait(): 让占用了这个同步对象的线程,临时释放当前的占用,并且等待。
wait()方法是使当前线程临时暂停,释放锁,并进入等待,其功能类似于sleep()方法,但是wait()需要释放锁,而sleep()不需要释放锁。
任务:
完善代码,利用wait()和notify()实现线程之间的交互。将关键代码写到实验报告中。
还是用模拟多线程下载的例子。
DownloadStatus
首先我们在 DownloadStatus
中新建一个 isDone
布尔变量并将其声明为volatile,来表明这个下载任务是否已经完成。并设定一个 getter
返回isDone
和一个 setter
将isDown
设为True
1 | package com.company.concurrency; |
然后在DownloadFileTask
类中,我们在下载结束后调用 status.done()
将isDone()
设置为True并输出Download complete
1 | package com.company.concurrency; |
最后在ThreadDemo
类中,新建两个线程,第一个线程传入DownloadFileTask对象,第二个线程里面是个 Lambda表达式,它会一直询问status中的变量isDone是否为True,一直到下载完成 ,isDone==True,才会跳出循环并输出totalBytes
的值
1 | package com.company.concurrency; |
刚才例子中,一直询问下载完成没。但是这样是很占用CPU的资源的。它可能会重复循环上亿次才能等到结果。
为了优化上面这种情况,我们可以用wait()
和notify()
方法
顾名思义,调用wait()
方法后,线程进入等待状态,wait()
方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()
方法才会返回,然后,继续执行下一条语句。注意,只能在锁对象上调用wait()
方法 。notify()
则是在相同的锁对象上作用,完成某件事后发出一个信号,让wait()
去接收
比如下面这个例子,当我们要用while来询问isDone()
是否为true的时候,我们对status上了一个锁。然后在里面调用wait()
让线程2沉睡。再跑到DownloadFileTask
类中,当下载完成时我们在 status上锁了的情况下调用 notifyAll()
发出讯号。wait()
收到后就会跳出循环,执行打印命令。
通过这种机制我们可以降低CPU的负荷,优化程序性能。但同时,在不正确的地方使用wait()
和notify()
可能会造成很多难以解决的问题,因此我们不推荐这种方法。
进阶
问题1
编写一个程序,启动三个线程,三个线程的ID分别是A,B,C;每个线程将自己的ID值在屏幕上打印5遍,打印顺序是ABCABC…
MyThread
类:
这里我们声明总线程数totalThreads,一个计数器 count ,一个进程号ID
判断当前线程是否需要打印的逻辑: 计数器%总线程数 == 当前线程的ID ,则打印进程名字
id由有参构造函数确定,这里 打印A的线程ID=0 ,打印B的线程ID=1,打印C的线程ID=2
注意,这里 count 要设置为 volatile 的,static的,因为这个变量是多个线程共享的,必须从主存中读取。此外,因为这个程序不是很大,所以我没有使用wait()
和notifyAll()
1 | package com.company; |
Main
类:
在main方法中我们创建一个线程集合,然后利用有参构造创建三个线程对象,并规定他们的name分别是ABC
1 | package com.company; |
问题2
编写两个线程,一个线程负责打印字母,另一个线程负责打印数字,两个线程同时进行打印,要求打印出来的结果的形式为
a1b23c456d7891 ……z……(数字为1-9的循环)
问题2和问题1的底层逻辑其实是共通的.
只不过这里没有办法只创建一个线程类了,因此我把公共的部分抽出来构成了一个新的类PrintStatus
:
在这里我们设置了两个变量,一个是 volatile的计数器,我们用它来判断当前线程是否需要打印。第二个变量是totalThreads,用来记载当前线程数量。
1 | package com.company; |
Main
类:
1 | package com.company; |
CharPrinter
类:
1 | package com.company; |
NumberPrinter
类:
1 | package com.company; |
这里因为篇幅,我们只打印一个轮回就结束进程
问题3
编写程序,模拟三个窗口同时卖票,包括购票(可能存在购买多张的情况),退票(可能存在退多张的情况)和新进票,要求有余票时必须出售,无票时不能出售,购票时若无足量余票可选择继续等待或离开。
Main
类
1 | package com.company; |
线程:TicketWindow
类
1 | package com.company; |
TicketStatus
类,用来存储余票,各种操作的状态,买卖票数等。
1 | package com.company; |
结果如下:
问题4
编写10个线程,第一个线程从1加到10000,第二个线程从10001加20000…第十个线程从90001加到100000,最后再把10个线程结果相加,记录运行时间,并和串行相加时候的时间进行对比;编写50个线程,第一个线程从1加到10000,第二个线程从10001加20000……,最后再把50个线程结果相加,记录运行时间,并和串行相加时候的时间进行对比;编写100个线程,第一个线程从1加到10000,第二个线程从10001加20000……,最后再把100个线程结果相加,记录运行时间,并和串行相加时候的时间进行对比,给出对比结果。总结分析,单机情况下是不是线程越多越好,为什么。
串行代码:
1 | package com.company; |
并行代码:
1 | package com.company; |
Main
类:
1 | package com.company; |
这个模型和线程同步模块所讲的例子一样,不过我们要计算一下运行时间
线程数 | 串行时间 | 并行时间 |
---|---|---|
10 | 3 | 26 |
50 | 8 | 65 |
100 | 10 | 91 |
1000 | 11 | 498 |
10000 | 13 | 3018 |
我们发现在一台电脑的情况下,串行时间比并行时间要快很多。因为并行操作会发生线程创建和上下文切换的开销。线程越多开销越大,因此并不是线程越多越好。
总结
我在博客Java基础3中已经对多线程和并发做了详细的讨论了。但是这次的试验,又让我在复习的基础上对多线程编程有了更加深刻的认识。接下来的空闲时间需要学习Java的项目管理工具以及一些应用框架。