在学习线程之前先了解一下进程,在操作系统中,每个独立运行的程序都可以称为一个进程,也就是”正在运行的程序“。目前大部分计算机上安装的都是多任务操作系统。在多任务操作系统中,表面上看是支持进程并发执行的,实际上对于一个CPU而言,某个时刻只能运行一个程序,操作系统会给每一个进程分配一段有限的CPU使用时间,CPU在这段时间执行某个进程,然后在下一段时间切换到另一个进程中去执行,由于CPU运行速度很快,所以给人可以同时执行多个程序的感觉。
每个正在运行的程序都是一个进程,在一个进程中可以有多个执行单元同时运行,这些执行单元可以看作程序执行的一条条线索,被成为线程。操作系统中每一个进程中都至少存在一个线程。当一个Java程序启动时,就会产生一个进程,该进程会默认创建一个线程,在这个线程上会运行main()方法中的代码。
代码按照调用顺序,依次执行,没有出现两段代码交替运行的效果,这样的程序称为单线程程序。多段程序代码交替运行,即多线程程序,多线程程序在运行时,每个线程之间都是相互独立的,他们可以并发执行。在学习多线程之间,先看一下我们熟悉的单线程程序。 例一 Example1
public class Example1 { public static void main(String[] args) { MyThread myThread = new MyThread();//创建MyThread类的实例对象 myThread.run(); //调用MyThread类的run()方法 while(true){ //该循环是一个死循环,打印输出语句 System.out.println("main()方法在运行"); } } } public class MyThread { public void run(){ while(true){ //该循环是一个死循环,打印输出语句 System.out.println("MyThread类的run()方法在运行"); } } }运行结果如下图。 可以看出,程序一直在打印“MyThread类的run()方法在运行”,这是因为这是一个单线程程序,当调用MyThread类的run()方法时,循环会一直运行。因此MyThread类的打印语句会永远执行,而main()方法中的打印语句将违法得到执行。
如果希望例一中的两个while循环可以并发执行,那么就需要实现多线程。为此JDK提供了一个线程类Thread,通过继承Thread,并重写Thread中的run()方法可以实现多线程。在Thread类中,提供了一个start()方法用于启动新线程,线程启动后,会自动调用run()方法,接下来通过例二来演示通过继承Thread类来实现多线程。 例二 Example2
public class Example2 { public static void main(String[] args) { MyThread myThread = new MyThread(); //创建MyThread类的线程对象 myThread.start(); //开启线程 while(true){ //通过死循环打印输出语句 System.out.println("main()方法在运行"); } } } public class MyThread extends Thread { //继承Thread类 public void run(){ //重写Thread类中的run()方法 while(true){ //通过死循环打印输出语句 System.out.println("MyThread类的run()方法在运行"); } } }运行结果如下 从例二的运行结果可以看到,两个while循环打印语句轮流执行了,说明实现了多线程。
通过继承Thread类实现多线程这种方式具有一定的局限性,因为Java中只支持单继承,一个类一旦继承了某个父类,就无法再继承Thread类,比如学生Student类继承了父类Person类就无法通过继承Thread类来创建线程。为了克服这种弊端,Thread类提供了另一种构造方法Thread(Runnable target),其中Runnable是一个接口,它只有一个run()方法。当通过Thread(Runnable target)构造方法创建线程对象时,只需为该方法传递一个实现了Runnable接口的实例对象,这样创建的线程将调用实现了Runnable接口中的run()方法作为运行代码,而不需要调用Thread类中的run()方法。
例三 Example3
public class Example3 { public static void main(String[] args) { MyThread myThread = new MyThread(); //创建MyThread实例对象 Thread thread = new Thread(myThread); //创建线程对象 thread.start(); //开启线程,执行线程中的run()方法 while(true){ System.out.println("main()方法在运行"); } } } public class MyThread implements Runnable { public void run() { //线程的代码段,当调用start()方法时,线程从此处开始执行 // TODO Auto-generated method stub while(true){ System.out.println("MyThread类的run()方法在运行"); } } }运行结果如下 从例三的运行结果可以看到,MyThread类实现了Runnable接口,并重写了Runnable接口中的run()方法,通过Thread类的构造方法将MyThread类的实例对象作为参数传入,从运行结果上看,main()方法和run()方法的语句都执行了,说明实现了多线程。
既然直接继承Thread类和实现Runnable接口都能实现多线程,那么这两种实现多线程的方式在实际应用中有什么区别呢?接下来我们通过一个例子来分析。 假设售票厅有四个窗口发售某日某次列车的100张火车票,这100张火车票可以看作是共享资源,四个售票窗口需要创建四个线程。为了直观地观察每个售票窗口的售票情况,可以通过调用Thread的currentThread()方法得到当前线程的实例对象,然后调用getName()方法可以获取线程的名称。接下来,首先研究通过继承Thread类的方法实现多线程的案例。
例四 Example4
public class Example4 { public static void main(String[] args) { new TicketWindow().start();//创建一个线程对象TicketWindow并开启 new TicketWindow().start();//创建一个线程对象TicketWindow并开启 new TicketWindow().start();//创建一个线程对象TicketWindow并开启 new TicketWindow().start();//创建一个线程对象TicketWindow并开启 } } public class TicketWindow extends Thread { private int tickets = 100; public void run(){ while(true){ //通过死循环打印语句 if(tickets>0){ Thread th = Thread.currentThread(); //获取当前线程 String th_name = th.getName(); System.out.println(th_name+"正在发售第"+tickets--+"张票"); } } } }运行结果如下 从例四的运行结果可以看到,每张票都被打印了四次,出现这种现象的原因是四个线程并没有共享这100张票,在程序中创建了四个TicketWindow对象,就等于创建了四个售票程序,每个程序中都有100张票,每个线程都独立地处理各自的资源。需要注意的是,例四中每个线程都有自己的名字,主线程的默认名字是main,用户创建的第一分线程的默认名字是"Thread-0",第二个线程的默认名字是"Thread-1",依次类推。如果希望指定线程的名字,则可以通过调用setName(String name)为线程设置名字。
由于现实中铁路系统的票资源是共享的,因此上面的运行结果显然不合理,为了保证资源共享,在程序中只能创建一个售票对象,然后开启多个线程去运行同一个售票对象的售票方法,简单来说就是四个线程运行同一个售票程序,这时就需要用到多线程的第二种实现方式,如下例五。
例五 Example5
public class Example5 { public static void main(String[] args) { TicketWindow tw = new TicketWindow();//创建TicketWindow实例对象 new Thread(tw,"窗口1").start(); //创建线程对象并命名为窗口1,开启线程 new Thread(tw,"窗口2").start(); //创建线程对象并命名为窗口2,开启线程 new Thread(tw,"窗口3").start(); //创建线程对象并命名为窗口3,开启线程 new Thread(tw,"窗口4").start(); //创建线程对象并命名为窗口4,开启线程 } } public class TicketWindow implements Runnable { private int tickets = 100; @Override public void run() { while(true){ if(tickets>0){ Thread th = Thread.currentThread(); //获取当前线程 String th_name = th.getName(); //获取当前线程名字 System.out.println(th_name+"正在发售第"+tickets--+"张票"); } } } }运行结果如下 例五只创建了一个TicketWindow对象,然后创建了四个线程,在每个线程上都去调用这个TicketWindow对象中的run()方法,这样就可以保证四个线程访问的是一个tickets变量,共享100张车票。 通过例四和例五两个例子可以看出,通过实现Runnable接口相对于继承Thread类来说有如下优点: (1)适合多个相同程序代码的线程去处理同一个资源的情况,把线程通同程序代码、数据有效的分离,很好地体现了面向对象的设计思想。 (2)可以避免由于Java单继承带来的局限性。在开发过程中经常遇到使用一个已经继承了某一个类的子类来创建线程,由于一个类不能同时有两个父类,所以不能用继承Thread类的方式,那么就只能采用实现Runnable接口的方式。
事实上,大部分的应用程序都会采用实现了Runnable接口的这种方式来创建多线程。
在上面的售票系统案例中,当main()方法中的创建并启动四个新线程后,main()方法中的代码执行完毕,这时方法会结束,main线程也随之结束了。通过程序的运行结果可以看出,虽然main线程结束了,但是整个java程序却没有结束,仍然在执行着售票的代码。对Java程序来说,只要还有一个前台线程在运行,这个进程就不会结束,如果一个进程中只有后台线程运行,这个进程就会结束。这里提到的后台线程和前台线程是一种相对概念,新创建的线程默认都是前台线程,如果某个线程对象在启动之前调用可setDeamon(true)语句,这个线程就变成一个后台线程。接下来通过一个案例演示当程序只有后台线程时就会结束的情况。
例六 Example6
public class Example6 { public static void main(String[] args) { System.out.println("main线程时后台线程吗?"+Thread.currentThread().isDaemon()); DamonThread dt = new DamonThread(); //创建一DeamonThread对象dt Thread t = new Thread(dt,"后台线程"); //创建线程t共享dt资源 System.out.println("t线程是默认后台线程吗?"+t.isDaemon()); //判断是否为后台线程 t.setDaemon(true); t.start(); for(int i=0;i<10;i++){ System.out.println(i); } } } public class DamonThread implements Runnable {//创建DamonThread类,实现Runnable接口 @Override public void run() { //实现接口中的run()方法 while(true){ System.out.println(Thread.currentThread().getName()+"---is running."); } } }运行结果如下 例六演示了一个后台线程结束的过程,当开启线程t时,会执行死循环中的打印语句,但我们将线程t设置为后台进程,当前台线程死亡后,JVM会通知后台线程。由于后台线程从接受指令到做出响应,需要一定的时间,因此打印了几次“后台线程—is running.”语句后,后台线程也就随之结束了。由此说明进程中只有后台线程运行时,进程就会结束。 注意:要将某个线程设置为后台线程,必须在线程启动之前,也就是说setDaemon()方法必须在start()方法之前调用,否则会引发IllegalThreadStateException异常。
在java中,任何对象都有生命周期,线程也不例外,它也有自己的生命周期。当Thread对象创建完成时,线程的生命周期便开始了。当run()方法中的代码正常执行完毕或者线程抛出一个未捕获的异常(Exception)或者错误(Error)时,线程的生命周期就会结束。 线程的身份名周期分为武哥阶段,分别是:新建状态(New),就绪状态(Runnable),运行状态(Running),阻塞状态(Blockd)和死亡状态(Terminated),线程的不同状态表明了线程当前正在进行的活动。在程序中,通过一些操作,可以使线程在不同状态之间进行转换。 下面针对线程生命周期中的五种状态分别进行详解。
创建一个线程对象以后,该线程对象就处于新建状态,此时它不能运行,和其他Java对象一样,仅仅由java虚拟机为其分配了内存,没有表现出任何线程的动态特征。
当线程对象调用了start()方法后,该线程就进入了就绪状态(也称为可运行状态)。处于就绪状态的线程位于可运行池中,此时它具备了运行的条件,能否获得CPU的使用权,还需要等待系统的调度。
如果处于就绪状态的线程获得了CPU的使用权,开始执行run()方法中的线程执行体,则该线程处于运行状态。当一个线程启动后,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就结束了),当使用完系统分配的时间后,系统就会剥夺该线程占用的CPU资源,让其他线程获得执行的机会,需要注意的是,只有处于就绪状态的线程才可能转换到运行状态。
一个正在执行的线程在某些特殊情况下,如执行耗时的输入/输出时,会放弃CPU的使用权,进入阻塞状态。线程进入阻塞状态后,就不能进入排队队列。只有当引起阻塞的原因消除后,线程才可以转入就绪状态。 下列就列举一下线程由运行状态转成阻塞状态的原因,以及如何从阻塞状态转换成就绪状态。
当线程试图获取某个对象的同步锁时,如果该锁被其他线程所持有,则当前线程会进入阻塞状态,如果想从阻塞状态进入就绪状态就必须获取到其他线程所持有的锁。当线程调用了一个阻塞式的IO方法时,也会使线程进入阻塞状态,如果想进入就绪状态就必须要等到这个阻塞的IO方法返回。当线程调用了某个对象的wait()方法时,也会使线程进入阻塞状态,如果想进入就绪状态就需要使用notify()方法唤醒该线程。当线程调用了Thread的sleep(long mills)方法时,也会使线程进入阻塞状态,在这种情况下,只需等到线程睡眠时间到了以后,线程就会自动进入就绪状态。当一个线程调用了另一个线程的join()方法时,会使当前线程进入阻塞状态,在这种情况下,需要等到新加入的线程运行结束后才会结束阻塞状态,今日就绪状态。需要注意的是,线程从阻塞状态只能进入就绪状态,而不能直接进入运行状态,也就是说结束阻塞状态的线程需要重新进入可运行池中,等待系统的调度。
线程的run()方法正常执行完毕或者线程抛出一个未捕获的异常(Exception)、错误(Error),线程就进入死亡状态。一旦进入死亡状态,线程将不再拥有运行的资格,也不能再转换到其他状态。
程序中的多个线程是并发执行的,某个线程若像被执行必须要获得CPU的使用权。Java虚拟机会按照特定的机制为程序中的每个线程分配CPU的使用权,这种机制被称为线程的调度。 在计算机中,线程的调度有两种模型,分别是分时调度模型和抢占式调度模型。所谓的分时调度模型是指所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用CPU的时间片。抢占式调度模型是指可运行池中优先级高的线程优先占用CPU,而对于优先级相同的线程,随机选择一个线程使其占用CPU,当它失去了CPU的使用权后,再随机选择其他线程获取CPU使用权。Java虚拟机默认采用抢占式调度模型。大多数情况下,程序员不需要关心它,但在某些特定的需求下需要改变这种模式,由程序资金及来控制CPU的调度。
在应用程序中,如果要对线程进行调度,最直接的方式就是设置线程的优先级。优先级越高的线程获CPU执行的机会就越大,而优先级越低的线程获得CPU执行的机会就越小。线程的优先级用1~10之间的整数来表示,数字越大优先级越高。除了可以直接使用数字表示优先级,还可以用Thread类提供的三个静态常量表示线程的优先级,如表一所示。 表一
Thread类得静态常量功能描述static int MAX_PRIORITY表示线程的最高优先级,相当于值10static int MIN_PRORITY表示线程的最低优先级, 相当于值1static int NORM_PRORITY表示线程的普通优先级,相当于值5程序在运行期间,处于就绪状态的每个线程都有自己的优先级,例如main线程具有普通的优先级。然而线程优先级不是固定不变的,可以通过Thread类的setPriority(int newPriority)方法对其进行设置,该方法中的参数newPriority接收的是1~10之间的整数或者Thread类的三个静态常量。接下来通过一个案例来演示不同优先级的两个线程的运行情况。 例七 Example7
public class MaxPriority implements Runnable { @Override public void run() { // TODO Auto-generated method stub for(int i = 0;i < 10; i++){ System.out.println(Thread.currentThread().getName()+"正在输出"+i); } } } public class MinPriority implements Runnable { @Override public void run() { // TODO Auto-generated method stub for(int i = 0; i < 10 ; i++){ System.out.println(Thread.currentThread().getName()+"正在输出:"+i); } } } public class Example7 { public static void main(String[] args) { //创建两个线程 Thread minPriority = new Thread(new MinPriority(),"优先级较低的线程"); Thread maxPriority = new Thread(new MaxPriority(),"优先级较高的线程"); minPriority.setPriority(Thread.MIN_PRIORITY); maxPriority.setPriority(10); //开启两个线程 maxPriority.start(); minPriority.start(); } }运行结果如下 例七创建了两个线程minPriority和maxPriority,分别将线程的优先级设置为1和10,从运行结果看,优先级较高的maxPriority线程获得CPU执行的机会大,所以先运行,优先级低的线程minPriority线程获得CPU执行的机会小,所以后开始运行。 需要注意的是,虽然Java中提供了10个线程优先级,但是这些优先级需要操作系统的支持,不同的操作系统对优先级的支持是不一样的,不能很好的和Java中的线程优先级一一对应,因此,在设计多线程应用程序时,其功能的实现一定不能依赖线程的优先级,而只能把线程优先级作为一种提高程序效率的手段。
上面我们讲了线程的优先级,优先级高的程序会获得CPU执行的机会大,优先级低的程序获得CPU执行的机会小,如果希望人为地控制线程,使正在执行的线程暂停,将CPU让给别的线程,这时可以使用静态方法sleep(long millis),该方法可以让当前正在执行的线程暂停一段时间,进入休眠等待状态。当前线程调用sleep(long millis)方法后,在指定时间(参数millis)内是不会执行的,这样其他的线程就可以得到执行的机会了。 sleep(long millis)方法声明抛出InterruptedException异常,因此在调用该方法时应该捕获异常,或者声明抛出异常。接下来通过一个案例来演示一下sleep(long millis)方法在程序中的使用。 例八 Example8
public class Example8 { public static void main(String[] args) throws Exception { new Thread(new SleepThread()).start(); for(int i = 1;i < 10;i++){ if(i == 5){ Thread.sleep(2000); //当前线程休眠而2秒 } System.out.println("主线程正在输出:"+i); Thread.sleep(500); } } } //定义SleepThread类实现Runnable接口 public class SleepThread implements Runnable { @Override public void run() { // TODO Auto-generated method stub for(int i = 1; i < 10; i++){ if(i==3){ try{ Thread.sleep(2000); //当前线程休眠2秒 }catch(InterruptedException e){ e.printStackTrace(); } } System.out.println("线程一正在输出:"+i); try{ Thread.sleep(500); //当前线程休眠500毫秒 }catch(InterruptedException e){ e.printStackTrace(); } } } }运行结果如下 例八中开启了两个线程,在这两个线程中分别调用了Thread的sleep(500)的方法,目的是让一个线程在打印一次后休眠500ms,从而使另一个线程获得执行的机会,这样就可以实现两个线程交替执行。 在线程一的for循环中,当i=3时,调用了Thread的sleep(2000)方法,使线程休眠2s。从运行结果可以看出,主线程输出2后,线程一直没有交替输出3,而是主线程接着输出了3和4,这说明了线程一进入了休眠等待状态。 在主线程的for循环中,当i=5时,也调用了Thread的sleep(2000)方法,使线程休眠2s。从运行结果可以看出,在主线程输出4后,下面连续2句话都是线程一输出的。只有当线程2s休眠完毕后,两个线程才会恢复交替执行。 需要注意的是,sleep()是静态方法,只能控制当前正在运行的线程休眠,而不能控制其他线程休眠。当休眠结束后,线程就会返回到就绪状态,而不是立即运行。
在校园中,我们经常看到同学们互相抢篮球,当某个同学抢到篮球就可以拍一会,之后他会把篮球让出来,大家重新开始抢篮球,这个过程就相当于Java程序中的线程让步。线程让步可以通过yield()方法来实现,该方法和sleep()方法有点相似,都可以让当前正在运行的线程暂停,区别在于yield()的方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次。当某个线程调用yield()方法之后,只有与当前线程优先级相同或者更高的线程才能获得执行的机会。接下来通过一个案例来演示一下yield()方法的使用,如例九所示。 例九 Example9
public class YieldThread extends Thread { public YieldThread(String name){ super(name); } public void run(){ for(int i = 0;i < 5;i++){ System.out.println(Thread.currentThread().getName()+"----"+i); if(i == 3){ System.out.println("线程让步"); Thread.yield(); //线程运行到此做出让步 } } } } public class Example9 { public static void main(String[] args) { //创建两个线程 Thread t1 = new YieldThread("线程A"); Thread t2 = new YieldThread("线程B"); //开启两个线程 t1.start(); t2.start(); } }运行结果如下 例九中创建了两个线程t1和t2,它们的优先级相同,两个线程在循环变量i=3时,都会调用Thread的yield()方法,使当前线程暂停,这时另一个线程就会获得执行,从运行结果看,当线程A输出3后,会做出让步,让B线程继续执行,同样
现实生活中经常能碰到“插队”的情况,同样,在Thread类中也提供一个join()方法来实现这个功能。当某个线程调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()这个方法加入的线程执行完毕之后它才会继续执行,接下来通过一个案例来演示join()方法的使用。如例十所示。 例十 Example10
public class EmergencyThread implements Runnable { @Override public void run() { // TODO Auto-generated method stub for(int i = 1;i < 6;i++){ System.out.println(Thread.currentThread().getName()+"输入:"+i); try { Thread.sleep(500); } catch (InterruptedException e) { // TODO: handle exception e.printStackTrace(); } } } } public class Example10 { public static void main(String[] args) throws Exception { //创建线程 Thread t = new Thread(new EmergencyThread(),"线程一"); t.start(); //开启线程 for(int i =1;i < 6;i++){ System.out.println(Thread.currentThread().getName()+"输入:"+i); if(i==2){ t.join(); //调用join()方法 } Thread.sleep(500); } } }运行结果如下
例十中的main线程开启了一个线程t,两个线程的循环体中都调用了Thread的sleep(500)方法,以实现两个线程的交替执行。当main线程中的循环变量为2时,调用t线程的join()方法,这时t线程会“插队”优先执行。从运行结果看,当main线程输出2以后,线程一九开始执行,直到线程一执行完毕,main线程才继续执行。
上面讲过多线程并发执行可以提高程序效率,但是,当多个线程去访问同一个资源的时候,也会引发一些安全问题。例如,当统计一个班级的学生数目时,如果有同学进进出出,则很难正确统计。为了解决这样的问题,需要实现多线程的同步,即限制某一个资源在同一时刻只能被一个线程访问。接下来将详细讲解多线程出中出现的问题以及如何使用同步来解决。
在例五的售票系统中,极有可能喷到意外的情况,如一张票被打印了多次或者打印出来的票号为0甚至是负数。这些意外都是由多线程操作共享资源ticket所导致的线程安全问题,接下来对例五进行修改,模拟四个窗口出售10张票,并在售票的代码中使每次售票使线程休眠10ms,如例十一所示。 例十一 Example11
//定义SaleThread实现了Runnable接口 public class SaleThread implements Runnable { private int tickets = 10; @Override public void run() { // TODO Auto-generated method stub while(tickets > 0){ try { Thread.sleep(10); } catch (InterruptedException e) { // TODO: handle exception e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+"---卖出的票"+tickets--); } } } public class Example11 { public static void main(String[] args) { SaleThread saleThread = new SaleThread(); //创建并开启四个线程 new Thread(saleThread,"线程一").start(); new Thread(saleThread,"线程二").start(); new Thread(saleThread,"线程三").start(); new Thread(saleThread,"线程四").start(); } }运行结果如下 最后几行打印售出的票为0和负数,这种现象是不应该出现的,因为在售票程序中做了判断,只有当票号大于0时才会进行售票。运行结果中之所以出现了负数是因为多线程在售票时出现了安全问题。接下来对问题进行简单的分析。 在售票程序的while循环中添加了sleep()方法,这样就模拟了售票过程中线程的延迟。由于线程有延迟,当票号减为1时,假设线程1次是出售1号票,对票号进行判断后,进入while循环,在售票之前通过sleep()方法让线程休眠,这时线程2会进行售票,由于此时票号仍为1,因此线程2也会进入循环,同理,四个线程都会进入while循环,休眠结束后,四个线程都会进行售票,这样就相当于将票号减了四次,结果中就出现了0、-1这样的票号。
通过上面的案例我们了解到线程安全问题是由多个线程同同时处理共享资源所导致的,因此想解决例十一中的线程安全问题,必须得保证下面用于处理共享资源得代码在任何时刻都只能有一个线程访问。 为了实现这种限制,Java中提供了同步机制。当多个线程使用同一个共享资源时,可以将处理共享资源得代码防止在一个代码块中,使用synchronized关键字来修饰,被称作同步代码块,其语法格式如下:
synchronized(lock){ //操作共享资源代码块 }上面得代码中,Lock是一个锁对象,它是同步代码块得关键,当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1,此时线程会执行同步代码块,同时将锁对象的标志位置为0。当一个新的线程执行到这段同步代码块后,锁对象的标志位被置为1,新线程才能进入同步代码块执行其中的代码。循环往复,直到共享资源被处理完为止。这个过程就好比一个公用电话亭,只有前一个人打完电话出来以后,后面的人才可以打。 接下来把例十一的得代码放到synchronized区域中,如例十二所示
public class Example12 { public static void main(String[] args) { Ticket1 ticket = new Ticket1(); //创建Tickets对象 //创建并开启四个线程 new Thread(ticket,"线程一").start(); new Thread(ticket,"线程二").start(); new Thread(ticket,"线程三").start(); new Thread(ticket,"线程四").start(); } } //定义Ticket1实现Runnable接口 public class Ticket1 implements Runnable { private int tickets = 10; Object lock = new Object(); @Override public void run() { // TODO Auto-generated method stub while(true){ synchronized(lock){ try { Thread.sleep(10); } catch (InterruptedException e) { // TODO: handle exception e.printStackTrace(); } if(tickets>0){ System.out.println(Thread.currentThread().getName()+"---卖出的票"+tickets--); }else{ break; } } } } }运行结果如下 例十二中,将有关tickets的变量的操作全部都放到同步代码块中,为了保证线程的持续执行,将同步代码块放到死循环中,直到tickets<0时跳出循环。因此,从运行结果可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。运行结果中并没有出现线程一售票的语句,出现这样的情况是很正常的,这是因为线程在获得锁对象时有一定的随机性,在整个程序运行期间,程序一始终未获得锁对象。 注意:同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是唯一的。任意说的是共享锁的类型,所以,锁对象的创建代码不能放到run()方法中,否则,每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位。线程之间便不能产生同步的效果。
通过上面的学习我们了解到同步代码块可以有效地解决线程安全问题,当把共享资源的操作放在synchronized定义的区域内时,便为这些操作加了同步锁。在方法前同样使用synchronized关键字来修饰,被修饰的方法为同步方法,它能实现和同步代码块同样的功能,具体语法格式如下:
synchronized 返回值类型 方法名 ([参数1,.......]){ }被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到线程访问完毕后,其他线程才有机会执行方法。接下来使用同步方法对例十二进行修改,如例十三所示。 例十三 Example13
public class Example13 { public static void main(String[] args) { Ticket1 ticket = new Ticket1(); //创建并开启四个线程 new Thread(ticket,"线程一").start(); new Thread(ticket,"线程二").start(); new Thread(ticket,"线程三").start(); new Thread(ticket,"线程四").start(); } } //定义Ticket1类实现Runnable接口 public class Ticket1 implements Runnable { private int tickets = 10; @Override public void run() { // TODO Auto-generated method stub while(true){ saleTicket(); if(tickets<=0){ break; } } } //定义一个同步方法saleTicket() private synchronized void saleTicket(){ if(tickets>0){ try { Thread.sleep(100); } catch (InterruptedException e) { // TODO: handle exception e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+"---卖出的票"+tickets--); } } }运行结果如下 例十三中,将售票代码抽取为售票方法saleTicket(),并用synchronized关键字把saleTicket()方法修饰为同步方法,然后在run()方法中调用该方法。从运行结果可以看出,同样没有出现0或者负数的票,说明了同步方法实现了和同步代码块一样的效果。 同步代买块和同步方法在解决多线程问题有好处也有弊端,同步解决了多个线程同时访问共享数据时的线程安全问题,只要加上同一个锁,在同一时间内之能有一条线程执行。但是线程执行同步代码块时每次都会判断锁的状态,非常耗资源,效率较低。
有这样一个场景:一个中国人和一个美国人在一起吃饭,美国人拿了中国人的筷子,中国人拿了美国人的刀叉,两个人开始争论不休: 中国人:“你先给我筷子,我再给你刀叉!” 美国人:“你先给我刀叉,我再给你筷子!” … 结果可想而知,两个人都吃不到饭,这个例子中的中国人和美国人相当于不同的线程,筷子和刀叉相当于锁。两个线程在运行时都等着对方的锁,这样便造成了程序的停滞,这种现象叫死锁。接下来通过一个案例来模拟死锁问题,如例十四所示。 例十四 Example14
public class DeadLockThread implements Runnable { static Object chopsticks = new Object(); //定义一个Object类型的chopsticks锁对象 static Object knifeAndFork = new Object(); //定义一个knifeAndFork类型的chopsticks锁对象 private boolean flag; //定义 boolean类型的变量flag DeadLockThread(boolean flag){ //定义有参构造方法 this.flag = flag; } @Override public void run() { // TODO Auto-generated method stub if(flag){ while(true){ synchronized (chopsticks){ //chopsticks锁对象上的同步代码块 System.out.println(Thread.currentThread().getName()+"---if---chopticks"); synchronized (knifeAndFork){ //knifeAndFork锁对象上的同步代码块 System.out.println(Thread.currentThread().getName()+"---if---knifeAndFork"); } } } }else{ while(true){ synchronized (knifeAndFork){ //knifeAndFork锁对象上的同步代码块 System.out.println(Thread.currentThread().getName()+"---else---knifeAndFork"); synchronized (chopsticks){ //chopsticks锁对象上的同步代码块 System.out.println(Thread.currentThread().getName()+"---else---chopsticks"); } } } } } } public class Example14 { public static void main(String[] args) { //创建两个DeadLockThread对象 DeadLockThread d1 = new DeadLockThread(true); DeadLockThread d2 = new DeadLockThread(false); //创建并开启两个线程 new Thread(d1,"Chinese").start(); new Thread(d2,"American").start(); } }运行结果如下 例十四中,创建了Chinese和America两个线程,分别执行run()方法中的if和else代码块中的同步代码块。Chinese线程中拥有chopticks锁,只有获得knifeAndFork锁才能执行完毕,而America线程拥有knifeAndFork锁,只有获得chopsticks锁才能执行完毕,两个线程都需要对方所占用的锁,但是都无法示方自己锁拥有的锁,于是这两个线程都处于挂起状态,从而造成了例十八图示的死锁。
现代社会崇尚合作精神,分工合作在日常生活和工作中无处不在。举个简单的例子,比如一条生产线上的两个工序,他们必须以规定的速率来完成各自的工作,才能保证产品在流水线中顺利地流转。如果下工序过慢,就会造成产品在两道工序之间的积压;如果上工序过慢,就会造成下工序长时间无事可做。在多线程的程序中,上下工序可以看作是两个线程,这两个线程之间需要协同完成工作,就需要线程之间进行通讯。
为了更好地理解线程间地通讯,我们可以模拟这样一种应用场景,假设两个线程同时去操作同一个存储空间,其中一个线程负责向存储空间存入数据,另一个线程负责去除数据。通过一个案例来实现上述情况,首先定义一个类,在类中使用一个数组来表示存储空间,并提供数据的存取方法,具体实现如例十五所示。 例十五 Example15
public class Storage { private int[] cells = new int[10]; //数据存储数组 private int inPos,outPos; //inPos表示存入时的数组下标,outPos表示取出时的数组下标 public void put(int num){ cells[inPos] = num; System.out.println("在cells["+inPos+"]放入数据-----"+cells[inPos]); inPos++; //存元素让位置加1 if(inPos == cells.length){ inPos = 0; //当inPos为数组长度时,将其置为0 } } //定义一个get()方法从数组中取出数据 public void get(){ int data = cells[outPos]; System.out.println("从celss["+outPos+"]中取出数据"+data); outPos++; //取完元素让位置加1 if(outPos == cells.length){ outPos = 0; } } }例十五中,定义的数组cells用来存储数据,put()方法用来向数组存入数据,get()方法用于获取数据。针对数组元素的存取操作都是从第一个元素开始依次进行是的哇,每当操作完数组的最后一个元素时,索引都会被置为0,也就是重新从数组的第一个位置开始存取操作。 接下来实现两个线程同时访问上例中的共享数据,这两个线程都需要实现Runnable接口,具体如例十六。 例十六 Example16
public class Output implements Runnable { //输出线程类 private Storage st; Output(Storage st){ this.st = st; } @Override public void run() { // TODO Auto-generated method stub while(true){ st.get(); //循环取出元素 } } } public class Input implements Runnable { //输入线程类 private Storage st; private int num; Input(Storage st){ this.st = st; } @Override public void run() { // TODO Auto-generated method stub while(true){ st.put(num++); //将num存入数组,每次存入后num自增 } } }例十六中定义了两个类Input和Output,它们都是实现了Runnable接口,并且构造方法中都有一个Storage类型的对象。在Input类的run()方法中使用while循环不停地向存储空间中存入数据num,并在每次存入数据后将num进行自增,从而实现存入自然数1、2、3、4…的效果。在Output类的run()方法中使用while循环不停地从存储空间中取出数据。最后需要写一个测试程序,开启两个线程分别运行Input和Output类中的代码,如例十七所示。 例十七 Example17
public class Example17 { public static void main(String[] args) { Storage st = new Storage(); //创建数据存储类对象 Input input = new Input(st); //创建Input对象传入Storage对象 Output output = new Output(st); //创建Input对象传入Storage对象 new Thread(input).start(); //开启新线程 new Thread(output).start(); //开启新线程 } }如果想解决上述问题,就需要控制多个线程按照一定的顺序轮流执行,此时需要让线程间进行通信。在Object类中提供了wait()、notify()、notifyAll()方法用于解决线程间的通信问题,由于Java类中所有的类都是Object的子类或者间接子类,因此,任何类的实例对象都可以直接使用这些方法。接下来通过表二来详细说明这几个方法的作用。 表二
方法声明功能描述void wait()使当前线程放弃同步锁进入等待,直到其他线程进入同步锁,并调用notify()方法或notifyAll()方法唤醒该线程为止void notify()唤醒此同步锁上等待的第一个调用wait()方法的线程void wait()唤醒此同步锁上调用wait()方法的所有线程表二中列出了3个与线程通信相关的方法,其中wait()方法用于使当前线程进入等待状态,notify()方法和notifyAll()方法用于唤醒当前处于等待状态的线程。需要注意的是,wait()、notiify()和notifyAll()这三个方法的调用者都应该是同步锁的对象,如果这三个方法的调用者不是同步锁对象,Java虚拟机就会抛出IllegalMonitorStateException异常。 接下来通过使用wait()和notify()方法来对例十五进行改写实现线程间的通信,如例十八所示。
public class Storage { private int[] cells = new int[10]; //数据存入数组 private int inPos,outPos; //inPos存入时数组下标,outPos取出时数组下标 private int count; //存入或取出数据的数量 public synchronized void put(int num){ try { //如果放入的数据等于cells的长度,此线程等待 while(count == cells.length){ this.wait(); } cells[inPos] = num; System.out.println("在cells["+inPos+"]中放入数据---"+cells[inPos]); inPos++; if(inPos==cells.length){ inPos = 0; count++; this.notify(); } } catch (Exception e) { // TODO: handle exception e.printStackTrace(); } } public synchronized void get(){ try { while(count == 0){ //如果count等于0,此线程等待 this.wait(); } int data = cells[outPos]; //从数组中取出数据 System.out.println("从cells["+outPos+"]中取出数据"+data); cells[outPos] = 0; //取出后,当前位置的数据置为0 outPos++; //取完元素让位置加1 if(outPos == cells.length){ //当从cells[9]取完数据后再从cells[0]开始 outPos = 0; count--; //每取出一个元素count减1 this.notify(); } } catch (Exception e) { // TODO: handle exception e.printStackTrace(); } } }运行结果如下
例十八中的Storege是对例十五的改写,首先通过synchronized关键字将put()方法和get()方法修饰为同步方法,之后每一次操作数据,便调用一次notify()方法唤醒对应同步锁上等待的线程。当存入数据是,如果count的值与cells数组的长度相同,说明数组已经填满,此时就需要调用同步锁的wait()方法使存入数据的线程进入等待状态。同理,当取出数据时如果count的值为0,说明数组已经被取空,此时就需要调用同步锁wait()方法,使取出数据的线程进入等待状态。从运行结果看,存入的数据和取出的数据都是依次递增的自然数。
