实验简介
多线程技术是任何一门编程语言所必备的基本特性,同时也是目前的应用程序普遍使用的一种提升执行效率的方法。本实验主要为大家讲解在Java中,如何应用多线程技术,进行并发用户的处理,以及在多线程应用过程中的一些特殊注意事项。
实验目的
1.理解Java的原生多线程技术的操作方法。
2.理解Java中多线程的生命周期。
3.理解多线程的同步,中断,优先级,等待,唤醒,合并,死锁等情况。
4.对Java当中的一些线程不安全的情况有所认知。
实验流程
1.进程与线程。
目前主流的操作系统基本上是多任务操作系统,允许计算机在同一时刻同时运行多个程序。操作系统中独立执行的程序被称为进程。例如在Windows操作系统中可以同时运行Microsoft Word和Microsoft PowerPoint两个程序,这两个程序就是两个不同的进程。进程占有系统资源,拥有资源使用权。我们可以在Windows的任务管理器中看到一个一个的进程运行情况,如下图所示:
从上述任务管理器中我们可以看到,一个应用程序对应一个进程名称和进程ID号,也可以观察到这个应用程序对应的CPU,内在,磁盘和网络消耗情况。对于许多操作系统来说,一个进程中可包括一个或多个线程,线程是进程的实体,一个线程就是进程的一条执行路径,拥有多个线程的进程可以同时完成多种功能。例如在Microsoft Word中,我们可以边编辑文档边执行打印功能。多线程的并发执行通常是指在逻辑上的同时,并非物理上的同时。多线程程序设计中的各个线程彼此独立,这样各个线程可以乱序或交叉执行。
线程又被称为轻量级进程。较之于进程,线程相互之间的通信代价小,这使得多任务的线程比多任务的进程需要的开销要小。同一个进程中的多个线程共享内存等资源,这些线程可以访问相同的对象和变量,在使用进程时要注意对其它进程的影响。我们可以在任务管理器中打开资源监控器,看到每一个进程对应的线程数量及资源消耗情况,如下图所示:
2.线程的生命周期。
一个线程从诞生到死亡整个生存期内存在9个基本状态:
(1)新建(Born) :新建的线程处于新建状态,new的时候即为新建。
(2)就绪(Ready) :在创建线程后,它将处于就绪状态,等待start()方法被调用。
(3)运行(Running):线程在开始执行时进入运行状态,当run()方法执行时
(4)睡眠(Sleeping):线程的执行可通过使用sleep()方法来暂时中止。睡眠后,线程进入就绪状态。
(5)等待(Waiting):如果调用了wait()方法,线程将处于等待状态。
(6)挂起(Suspended):在临时停止或中断线程的执行时,线程就处于挂起状态。
(7)恢复(Resume):在挂起的线程被恢复执行时,可以说它已被恢复
(8)阻塞(Blocked):在线程等待一个事件时(例如输入/输出操作),就称其处于阻塞状态。
(9)死亡(Dead):在run()方法已完成执行或其stop()方法被调用之后,线程就处于死亡状态。
3.利用Thread类实现线程。
Thread类位于java.lang包中,实现java.lang.Runnable接口,继承java.lang.Object类。Thread是Java的进程管理的关键类,其中定义了大量的关于进程管理的操作方法,其常用方法如下:
方法定义 | 方法说明 |
public void run() | 线程体所在位置,由Java虚拟机调用,通常被覆写的方法 |
public void start() | 使该线程进入就绪态:Java虚拟机调用该线程的run()方法 |
public void interrupt() | 中断进程,但事实是线程会继续执行 |
public static void yield() | 暂停当前正在执行的线程对象,并执行其它线程 |
public static void sleep(long millis) | 在millis毫秒数内让 当前正在执行的线程休眠(暂停执行) |
public static void sleep(long millis,int nanos) | 在millis毫秒数加nanos纳秒数内让当前正在执行的线程休眠(暂停执行): |
public final void wait() | 导致当前线程等待,直到其它线 程调用此对象的notify()方法或notifyAll() 方法 |
public final void wait(long timeout) | 导致当前线程等待, 直到其它线程调用此对象的 notify() 方法或notifyAll() 方法,或者其它某个线程中断当前线程,或者超过timeout毫秒数 |
public final void wait(long timeout, int nanos) | 致当前线 程等待,直到其它线程调用此对象的notify() 方法或 notifyAll() 方法,或者其它某个线程 中断当前线程,或者超过timeout毫秒数加nanos纳秒数 |
public final void notify() | 唤醒在此对象监视器上等待的所有线程 |
public final void join() | 等待该线程终止 |
public final void join(long millis) | 等待该线程终止的时 间最长为 millis毫秒 |
public final void join(long millis, int nanos) | 等待该线程 终止的时间最长为 millis 毫秒加 nanos纳秒。 |
在 Java 中实现多线程的方法之一是创建一个类并让该类继承 Thread 类并覆盖类中的run()方法(该方法没有任何参数),具体代码如下:
package com.woniuxy.thread;
public class MyThread extends Thread { public static void main(String[] args) { // 实例化当前类,并调用start()方法 MyThread mt = new MyThread(); mt.start(); } // 重写父类的run方法,线程执行时会自动调用该方法 public void run() { System.out.println("当前线程名称为:" + this.getName()); } } |
当我们运行上述方法后,会在控制台打印一次当前线程的名称。这相当于新建了一个线程,那么如何新建多个线程呢?
其实方法也非常简单,那就是直接利用循环即可完成多线程的创建,请看如下代码:
package com.woniuxy.thread;
public class MyThread extends Thread { public static void main(String[] args) { // 实例化当前类,并调用start()方法 for (int i=0; i<10; i++) { MyThread mt = new MyThread(); mt.start(); } } // 重写父类的run方法,线程执行时会自动调用该方法 public void run() { System.out.println("当前线程名称为:" + this.getName()); } } |
上述代码中我们为MyThread类创建了10个线程来运行,运行结果的输出内容如下:
当前线程名称为:Thread-0 当前线程名称为:Thread-2 当前线程名称为:Thread-1 当前线程名称为:Thread-3 当前线程名称为:Thread-5 当前线程名称为:Thread-4 当前线程名称为:Thread-6 当前线程名称为:Thread-8 当前线程名称为:Thread-7 当前线程名称为:Thread-9 |
我们可以看到,线程的运行并非按顺序执行的,而是具备随机性。上述的线程状态经历了新建,就绪,运行和死亡四种状态,这也是一个线程常见的几种最基本的状态。此处我们需要注意的是,循环创建的MyThread实例是10个,对应于新建了10个线程,相应的每一个线程只运行1次,就叫多线程单循环,与我们平时的一个实例循环运行10次是完全不一样的效果。比如如果我们将上述代码的循环放在run()方法中,代码如下:
public class MyThread extends Thread { public static void main(String[] args) { MyThread mt = new MyThread(); mt.start(); }
public void run() { for (int i=0; i<10; i++) { System.out.println("当前线程名称为:" + this.getName()); } } } |
则最终的运行效果会变为一个线程的运行情况,线程名称只有一个:
当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 当前线程名称为:Thread-0 |
4.利用Runnable接口实现线程。
除了上述通过继承Thread类构造线程对象外,Java还提供了通过Runnable接口获得线程对象的方法。Runnable接口位于java.lang包中,只有一个run()方法。实现Runnable 接口的类通过实例化一个对象并将这个对象作为运行目标,就可以运行线程而无需创建Thread的子类。Runnable的接口与Thread超类的使用大致相同,以MyThread为例,其代码可以这样修改:
package com.woniuxy.thread;
public class MyRunnable implements Runnable { public static void main(String[] args) { MyRunnable mr = new MyRunnable(); for (int i=0; i<10; i++) { Thread t = new Thread(mr); t.start(); } } @Override public void run() { // 由于MyRunnable没有父类,我们不能直接使用this.getName方法 // 来获取当前线程的名称,而应该使用更加通用的方式获取 System.out.println("当前线程名称为:" + Thread.currentThread().getName()); } } |
上述代码的输入与MyThread类的输出是一模一样的。当然,通常情况下,我们并不建议在自己的类当中并发自己的类实例作为线程,这样比较容易搞混淆,技术上虽然是没有任何问题的。本书对于一些简单的知识点,将采取内部类的方式来执行,这样也会比较方便大家区分。
5.主线程与子线程。
通常情况下,任何一段Java代码都是通过main()方法来开始运行的,这个时候JVM或开启一个主线程用于执行main()方法及后续被调用的程序。
所以,我们将运行Java代码的这一个线程称之为主线程,而由主线程在执行代码过程中开启的线程称之为子线程。
但是这里需要特别注意的是,主线程一旦产生了子线程,那么子线程的命运便交由CPU来处理了,主线程与子线程再无关系,各自运行各自的任务,运行完成该结束就结束。但是对于进程而言,必须要等到所有的线程都运行结束后,进程才能正常结束。我们来看看下面一段代码:
package com.woniuxy.thread;
public class MainThread { public static void main(String[] args) { for (int i=1; i<=10; i++) { // 通过SubThread构造参数指定线程名称 SubThread sub = new SubThread("子线程-" + i); Thread t = new Thread(sub); t.start(); } // 主线程main方法正常运行,但是并不一定在最后运行 System.out.println("这是主线程在运行:" + Thread.currentThread().getName()); } }
class SubThread implements Runnable { private String threadName = ""; public SubThread() { } // 利用构造方法定义线程名称 public SubThread(String threadName) { this.threadName = threadName; } public void run() { System.out.println("当前正在运行线程:" + this.threadName); } } |
上述代码的运行的可能结果如下所示:
当前正在运行线程:子线程-1 当前正在运行线程:子线程-3 当前正在运行线程:子线程-2 当前正在运行线程:子线程-4 当前正在运行线程:子线程-5 这是主线程在运行:main 当前正在运行线程:子线程-6 当前正在运行线程:子线程-7 当前正在运行线程:子线程-8 当前正在运行线程:子线程-10 当前正在运行线程:子线程-9 |
在上述的代码中,我们利用主线程main开启了10个子线程,但是一旦线程被开启,所有的线程的控制权便无法由代码来掌控,只有交给CPU任其安排,我们无法完全控制。同时,在上述代码中,大家也可以看到,无论主线程还是子线程,都需要平等地去抢占CPU资源,虽然叫主线程,但是并没有最高优先权。我们也可以将主线程运行的这段代码放在开启子线程的前面,这样的话,由于代码运行顺序的原因,主线程理论上来说应该是最先被执行。
另外,上述代码中也为大家演示了如何利用构造参数的方法为子线程设置线程名称,这样更便于我们控制线程的具体行为,比如让某个线程暂停5秒钟后运行,修改SubThread类的run()方法代码如下:
public void run() { // 如果线程名称中包含“6”,则暂停5秒后运行 if (this.threadName.contains("6")) { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("当前正在运行线程:" + this.threadName); } |
上述代码的运行过程中,线程名称包含字符串“6”的线程将会暂停5秒后才运行,这时我们可以看到,“子线程-6”将会最后运行,一旦运行结束,整个程序的进程才会结束。
6.线程的优先级。
虽然我们无法完全控制线程的运行顺序,但是我们可以通过指定线程执行时的优先级,请求CPU在调度时,面对两个同时抢占资源的线程,先让优先级的先运行。主要使用线程的方法“setPriority()”进行,其中参数可以传递1到10的整数,1表示优先级最低,10表示优先级最好。但是这并不一定完全奏效,关键还得看CPU当时的心情。我们来看看下面的代码:
package com.woniuxy.thread;
public class MainThread { public static void main(String[] args) { for (int i=1; i<=10; i++) { SubThread sub = new SubThread("子线程-" + i); Thread t = new Thread(sub); if (i == 5) { // 为第5个线程设置最高优先级 t.setPriority(10); } t.start(); } // 为主线程设置最低执行优先级 Thread.currentThread().setPriority(1); System.out.println("这是主线程在运行:" + Thread.currentThread().getName()); } }
class SubThread implements Runnable { private String threadName = ""; public SubThread() { } // 利用构造方法定义线程名称 public SubThread(String threadName) { this.threadName = threadName; } public void run() { System.out.println("当前正在运行线程:" + this.threadName); } } |
某次执行结果为:
当前正在运行线程:子线程-2 当前正在运行线程:子线程-3 当前正在运行线程:子线程-5 当前正在运行线程:子线程-1 当前正在运行线程:子线程-4 当前正在运行线程:子线程-8 当前正在运行线程:子线程-7 当前正在运行线程:子线程-9 这是主线程在运行:main 当前正在运行线程:子线程-10 当前正在运行线程:子线程-6 |
上述结果表明,线程5并没有获取到最优先运行的权利,而主线程main也并没有最后才运行。所有的线程优先级均是在同时抢占CPU资源时,CPU根据当时的心情来优先照顾优先级高的。但是如果是线程一前一后,有一定前后顺序,那么CPU一定是优先执行先发起请求的线程,这一点是任何优先级也改变不了的事实。
7.线程的中断。
一旦线程运行起来,很有可能我们需要在某些特定的情况下停止某个线程的运行,此时我们可以调用线程的interrupt()方法用于中断线程。但是这里需要特别注意的是,我们必须要使用线程是否被中断的状态isInterrupted()方法来进行处理才会有效:
package com.woniuxy.thread;
public class MainThread2 { public static void main(String[] args) throws Exception { StopThread st = new StopThread(); st.start(); Thread.sleep(1000); st.interrupt(); } }
class StopThread extends Thread { int i = 1; public void run() { // 当线程没有被中断时执行循环输出 while (!this.isInterrupted()) { System.out.println(this.getName() + "执行了 " + (++i) + " 次."); } } } |
8.线程的同步。
在多线程应用过程中可能会出现两个或多个线程同时访问同一个资源。例如:一个线程试图访问一个文件并读取相关信息,而另一个线程则试图修改这个文件里的内容。也或者一个两个线程同时执行一个银行转账的操作,此时由于我们很难控制线程的先后顺序,而导致一些“脏数据”的出现。我们先来看看下面的例子:
package com.woniuxy.thread;
public class BankDemo { public static void main(String[] args) { Pay p = new Pay(); ThreadA ta = new ThreadA(p); ThreadB tb = new ThreadB(p); ta.start(); tb.start(); } }
class Pay { private int balance = 1000; public void doPay(int money) { System.out.println("当前正在转账的线程是:" + Thread.currentThread().getName()); if (balance >= money) { try {Thread.sleep(1000);}catch(Exception e){} balance -= money; } else { System.out.println("你的余额不足."); } System.out.println("转账后的余额为:" + balance); } }
class ThreadA extends Thread { private Pay p = null; public ThreadA(Pay p) { this.p = p; } public void run() { this.p.doPay(600); } }
class ThreadB extends Thread { private Pay p = null; public ThreadB(Pay p) { this.p = p; } public void run() { this.p.doPay(600); } } |
上述代码中,为了保证两个线程使用的是同一个Pay的实例,我们在main方法中只实例化了一次Pay,并将该实例通过ThreadA和ThreadB的构造参数传递给两个线程实例。上述的代码最终的运行结果可能为为:
当前正在转账的线程是:Thread-0 当前正在转账的线程是:Thread-1 转账后的余额为:400 转账后的余额为:400 |
也有可能为:
当前正在转账的线程是:Thread-1 当前正在转账的线程是:Thread-0 转账后的余额为:400 转账后的余额为:-200 |
当然,还有可能为:
当前正在转账的线程是:Thread-0 当前正在转账的线程是:Thread-1 转账后的余额为:-200 转账后的余额为:-200 |
很显然,这样的输出结果是有严重问题的,我们期望的结果是第一次转账600后余额为400没有问题,但是第二个线程再去试图转账600时,应该提示余额不足,而不是也正常转账,而且还可以得出一个-200的余额,如果银行都这么干,迟早得破产。
事实上,这就是线程不同步导致的问题,解决这样的问题方法非常简单,只需要在doPay()方法上加上synchronized关键字即可,表示该方法是一个同步方法,同步后的方法可以保证线程独占该方法的运行,一个线程运行完成后,另外一个线程才能继续调用该方法运行。这一特点有点类似于排队的效果,或者说给当前线程调用该方法时加一把锁,只有当线程执行完这个方法的代码后,其它线程才能够来调用该方法。利用同步的处理,可以保证多线程环境中线程执行的有序性。上述的代码不需要任何修改,只是将“public void doPay(int money)”修改为“public synchronized void doPay(int money)”即可,这样的修改后,运行的结果始终为:
当前正在转账的线程是:Thread-0 转账后的余额为:400 当前正在转账的线程是:Thread-1 你的余额不足. 转账后的余额为:400 |
9.线程的其它特性。
除了上述比较重要的一些多线程特性外,还有如下一些特性需要我们做个简单的了解:
(1)线程让步:调用线程的yield()方法可以将当前线程让出控制权。
(2)线程等待:调用当前线程的wait()方法可以让当前线程处于暂停等待状态,直到其它线程调用notify()方法或者notifyAll()方法去唤醒该线程。此处需要注意的时,线程的等待和唤醒都需要在同步方法中进行。但是需要注意的是,一方面我们不能让一个线程先等待,然后再自己唤醒自己,另外一方面,我们也不能在同一个同步方法中先等待,再唤醒,这样唤醒的指令永远也不会被运行到。
(3)线程死锁:前面给大家讲到了利用同步方法时相当于给当前线程调用此方法上了一把锁。一旦上锁,如果不注意使用,很可能锁上了就打不开了,死锁的概念就是这个意思。假如有2个线程,一个线程想先锁对象1,再锁对象2,恰好另外有一个线程先锁对象2,再锁对象1。在这个过程中,当线程1把对象1锁好以后,就想去锁对象2,但是不巧,线程2已经把对象2锁上了,也正在尝试去锁对象1。什么时候结束呢,只有线程1把2个对象都锁上并把方法执行完,并且线程2把2个对象也都锁上并且把方法执行完毕,那么就结束了,但是,谁都不肯放掉已经锁上的对象,所以就没有结果,这种情况就叫做线程死锁。
(4) 线程合并:调用线程的join()方法便可实现线程合并,其作用是优先执行调用该方法的线程,再执行当前线程。比如我们在一个主线程中开启了两个子线程,如果调用两个子线程的join()方法,这样两个子线程将会先运行,最后才会运行主线程。
思考练习
1.请自行设计实验验证线程的等待和唤醒的工作过程。
2.请自行设计实验验证线程合并的特性。
3.请参考网络教程,自己动手来实现一个线程死锁的实验。