【THU笔记】Java多线程

多线程

线程的概念

程序、进程、多任务与线程

  1. 程序(program):程序是含有指令和数据的文件被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。

  2. 进程(process):进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。进程是操作系统资源分配和处理器调度的基本单位,拥有独立的代码、内部数据和运行状态,因此,频繁的进程状态的切换必然消耗大量的系统资源。系统运行一个程序即是一个进程从创建、运行到消亡的过程。简单地说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源,如CPU时间、内存空间、文件、输入输出设备的使用权等。换句话说,当程序在执行时,将会被操作系统载入内存中(占有内存空间),并且启动它的工作(执行的时候,就是占有CPU时间),然后就变成了所谓的进程。如每一个正在Windows操作系统上执行的程序,都可以视为一个进程。

    每个进程之间是独立的,除非利用某些通信管道来进行通信,或是通过操作系统产生交互作用,否则基本上各进程不知道(不需要,也不应该知道)彼此的存在。这就像是在Windows系统里,执行了记事本程序后又执行画图程序,系统中就会出现两个进程。如果需要,可以通过Windows所提供的剪切板功能在它们之间传递数据,但除此之外,一起执行它们和先后执行它们是没有什么差别的。

  3. 多任务(multi task):多任务是指在一个系统中可以同时运行多个进程,即有多个独立运行的任务,每一个任务对应一个进程。每一个进程都有一段专用的内存区域,即使是多次启动同一段程序产生不同的进程也是如此。所谓同时运行的进程,其实是指由操作系统将系统资源分配给各个进程,每个进程在CPU上交替运行。每个进程占有不同的内存空间,内存消耗很大,这使系统在不同的程序之间切换时开销很大,进程之间的通信速度很慢。

  4. 线程(thread):对于完全不相关的程序而言,在同时执行时,彼此的进程也不会做数据交换的工作,而可以完全独立地运行。但是对于同一程序所产生的多个进程,通常是因为程序设计者希望能加快整体工作的效率,运用多个进程协同工作。但在进程的概念中,进程是操作系统资源分配和处理器调度的基本单位,每一个进程的内部数据和状态都是完全独立的,所以即使它们是同一个程序所产生,也必须重复许多的数据复制工作,而且在交换彼此数据的时候,也要再使用一些进程间通信的机制。为了减少不必要的系统负担,引入了线程的概念,将资源分配和处理器调度的基本单位分离。进程只是资源分配的单位,线程是处理器调度的基本单位。一个进程包含一个以上的线程,一个进程中的线程只能使用该进程的资源和环境。

    线程也是一个执行中的程序,但线程是比进程更小的执行单位。对每个线程来说,它都有自身的产生、运行和消亡的过程,所以,线程也是一个动态的概念。一个进程在其执行过程中可以产生多个线程,形成多条执行路径。线程不能独立存在,必须存在于进程中。由于同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换的工作时,负担要比进程小得多。正因如此,线程也被称为负担轻的进程(light-weight process)。由于同一进程的各个线程之间可以共享相同的内存空间,并利用这些共享内存来完成数据交换、实时通信及必要的同步工作,所以各线程之间的通信速度很快,线程之间进行切换所占用的系统资源也较少。

    在一般传统的程序设计中主要是使用单一进程来完成一项工作,也就是单一线程。然而若利用时间分享的概念来完成工作使其更有效率,也就是说让CPU在同一时间段内执行一个程序中的多个程序段来完成工作,这就是多线程的概念。同一个进程中的不同线程,各有各的“程序代码执行位置”,但却分享进程中的各项资源,如程序区段、数据区段、已打开的文件等。所以,线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会互相影响。从另一角度说,进程属于操作系统的范畴,主要是在同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内同时执行一个以上的程序段。

    为了进一步说明线程的概念,现举一个生活中的例子来加以说明。母亲煮晚餐的整个过程可以看成是一个进程,在煮晚餐的时候,先从冰箱拿出菜来,之后便开始洗菜、切菜……一步一步地完成之后,便开始一道一道地烧菜,完毕之后再开始煮汤。整个过程以顺序的方式来完成晚餐,而整个过程就是一条线程。然而仔细想想这种一步一步地以顺序的方式完成所要做的工作,就会觉得这样做的效率太低了。因为等汤煮好之后,之前所烧的菜不是都凉了吗?所以说按顺序来完成工作的话,效率相当低。如果我们能在一定的时间内同时进行好几个工作,那效率就会大幅提高,这就是多线程的概念,它利用“时间分享”的概念来进行工作。也就是说,母亲可以一边洗菜、切菜,一边烧菜或煮汤。在同一段时间内同时进行多项工作,这就是多线程的概念。

    综上可知,所谓多线程就是同时执行一个以上的线程,执行一个线程不必等待另一个线程执行完后才进行,所有线程都可以发生在同一时刻。但操作系统并没有将多个线程看作多个独立的应用去实现线程的调度和管理以及资源分配。

注意:多任务与多线程是两个不同的概念,多任务是针对操作系统而言的,表示操作系统可以同时运行多个应用程序,而多线程是针对一个进程而言的,表示在一个进程内部可以同时执行多个线程。

并行与并发

并行:同一时刻有多个代码在处理器上执行,这往往需要多个处理器,如CPU等硬件的支持。

并发:在单处理器上,同一时刻只能执行一个代码,但在一个时间段内,这些代码交替执行,即所谓“微观串行,宏观并行”。

线程的状态与生命周期

每个Java程序都有一个默认的主线程,对于应用程序来说其主线程是main()方法执行的线程;对小程序来说,其主线程指挥浏览器加载并执行Java小程序。要想实现多线程,必须在主线程中创建新的线程对象来表示某段代码段。Java语言使用Thread类及其子类的对象来表示线程,新建线程在它的一个完整的生命周期内通常要经历五种状态。通过线程的控制与调度可使线程在这几种状态间转化。

  1. 新建状态(newborn):当一个Thread类或其子类的对象被声明并创建,但还未被执行的这段时间里,处于一种特殊的新建状态中。此时,线程对象已经被分配了内存空间和其他资源,并已被初始化,但是该线程尚未被调度。此时的线程可以被调度变成就绪状态。
  2. 就绪状态(runnable):就绪状态也称为可运行状态。处于新建状态的线程被启动后,将进入线程队列排队等待CPU资源,此时它已具备了运行的条件,也就是处于就绪状态。一旦轮到它来享用CPU资源时,就可以脱离创建它的主线程,独立开始自己的生命周期了。另外原来处于阻塞状态的线程被解除阻塞后也将进入就绪状态。
  3. 执行状态(running):当就绪状态的线程被调度并获得CPU资源时,便进入执行状态。该状态表示线程正在执行,该线程已经拥有了对CPU的控制权。每一个Thread类及其子类的对象都有一个重要的run()方法,该方法定义了这一类线程的操作和功能。当线程对象被调度执行时,它将自动调用本对象的run()方法,从该方法的第一条语句开始执行,一直到执行完毕,除非该线程主动让出CPU的控制权或者CPU的控制权被优先级更高的线程抢占。处于执行状态的线程在下列情况下将让出CPU的控制权:
    • 线程执行完毕
    • 有比当前线程优先级更高的线程处于就绪状态
    • 线程主动睡眠一段时间
    • 线程在等待某一资源
  4. 阻塞状态(blocked):一个正在执行的线程如果在某些特殊情况下,将让出CPU并暂时中止自己的执行,线程处于这种不可执行的状态被称为阻塞状态。阻塞状态是因为某种原因系统不能执行线程的状态,在这种状态下,即使CPU空闲也不能执行线程。下面几种情况可使得一个线程进入阻塞状态:一是调用sleep()或yield()方法;二是为等待一个条件变量,线程调用wait()方法;三是该线程与另一线程join()在一起。一个线程被阻塞时它不能进入排队队列,只有当引起阻塞的原因被消除时,线程才可以转入就绪状态,重新进到线程队列中排队等待CPU资源,以便从原来的暂停处继续执行。处于阻塞状态的线程通常需要由某些事件才能唤醒,至于由什么事件唤醒该线程,则取决于其阻塞的原因。处于睡眠状态的线程必须被阻塞一段固定的时间,当睡眠时间结束时就变成就绪状态;因等待资源或信息而被阻塞的线程则需要由一个外来事件唤醒。
  5. 消亡状态(dead):处于消亡状态的线程不具有继续执行的能力。导致线程消亡的原因有两个:一是正常运行的线程完成了它的全部工作,即执行完了run()方法的最后一条语句并退出;二是当进程因故停止运行时,该进程中的所有线程将被强行终止。当线程处于消亡状态,并且没有该线程对象的引用时,垃圾回收器会从内存中删除该线程对象。

线程的优先级与调度

优先级

在多线程系统中,每个线程都被赋予一个执行优先级。优先级决定了线程被CPU执行的优先顺序。优先级高的线程可以在一段时间内获得比优先级低的线程更多的执行时间。这样好像制造了不平等,然而却带来了效率。如果线程的优先级完全相等,就按照“先来先用”的原则进行调度。

Java语言中线程的优先级从低到高以整数1~10表示,共分为10级。Thread类有三个关于线程优先级的静态常量:MIN_PRIORITY表示最小优先级,通常为1;MAX_PRIORITY表示最高优先级,通常为10;NORM_PRIORITY表示普通优先级,默认值为5。

对于一个新建的线程,系统会遵循如下的原则为其指定优先级。

  1. 新建线程将继承创建它的父线程的优先级。父线程是指执行创建新线程对象语句所在的线程,它可能是程序的主线程,也可能是某一个用户自定义的线程。
  2. 一般情况下,主线程具有普遍优先级。

另外,如果想要改变线程的优先级,可以通过调用线程对象的setPriority()方法来进行设置。

调度

调度就是指在各个线程之间分配CPU资源。多个线程的并发执行实际上是通过一个调度来进行的。线程调度有两种模型:分时模型和抢占模型。在分时模型中,CPU资源是按照时间片来分配的,获得CPU资源的线程只能在指定的时间片内执行,一旦时间片使用完毕,就必须把CPU让给另一个处于就绪状态的线程。在分时模型中,线程本身不会让出CPU;在抢占模型中,当前活动的线程一旦获得执行权,将一直执行下去,直到执行完或由于某种原因主动放弃执行权。如在一个低优先级线程的执行过程中,又有一个高优先级的线程准备就绪,那么低优先级的线程就把CPU资源让给高优先级的线程。为了使低优先级的线程有机会执行,高优先级的线程应该不时地主动进入“睡眠”状态,而暂时让出CPU。Java语言支持的就是抢占式调度模型。

Java的Thread类与Runnable接口

Java语言中实现多线程的方法有两种:一种是继承java.lang包中的Thread类;另一种是用户在定义自己的类时实现Runnable接口。但不管采用哪种方法,都要用到Java语言类库中的Thread类以及相关的方法。

由于Java语言支持多线程的功能,所以只要发现程序的工作可以同时执行,就应该产生一个新的线程分头去执行。在一般情况下,如果程序中把工作分开同时进行,而且执行程序的机器只有一个CPU在工作,那么运算的时间并不会因为采取多线程的方式而减少,但是整体的感觉可能会比较好。

利用Thread类的子类来创建线程

Java语言的基本类库中已定义了Thread这个基本类,内置了一组方法,使程序利用该类提供的方法去产生一个新的线程、执行一个线程、终止一个线程的工作,或是查看线程的执行状态。

继承Thread类是实现线程的一种方法。Thread类的构造方法和常用方法如下:

Thread类的构造方法功能说明
public Thread()创建一个线程对象,此线程对象有个默认名称叫作"Thread-n",其中n是一个整数。使用这个构造方法,必须创建一个类去继承Thread类并重写父类的run()方法
public Thread(String name)创建一个线程对象,参数name指定了线程的名称
public Thread(Runnable target)创建一个线程对象,此线程对象有个默认名称叫作"Thread-n",其中n是一个整数。参数target的run()方法将被线程对象调用,作为其执行代码
public Thread(Runnable target,String name)综上
Thread类的常用 方法功能说明
public static Thread currentThread()返回当前正在执行的线程对象
public final String getName()返回线程的名称
public void start()使该线程由新建状态变为就绪状态。如果该线程已经是就绪状态,则产生IllegalStateException异常
public void run()该方法中写线程应执行的任务
public final boolean isAlive()如果线程处于就绪、阻塞或运行状态,则返回true;如果线程处于新建且没有启动的状态,或已经结束,则返回false
public void interrupt()当线程处于就绪状态或执行状态时,给该线程设置中断标志;一个正在执行的线程让睡眠的线程调用该方法,则可导致睡眠线程发生InterruptedException异常且唤醒睡眠线程,从而进入就绪状态
public static boolean isInterrupted()判断该线程是否被中断,若是则返回true,否则返回false
public final void join()线程A调用该方法来暂停当前线程的执行,转而执行线程A,等待自己结束后再继续执行被暂停的线程
public final int getPriority()返回线程的优先级
public final void setPriority(int newPriority)设置线程A的优先级。如果当前线程不能修改这个线程A的优先级,则产生SecurityException异常。如果参数不在所要求的优先级范围内,则产生IllegalArgumentException异常
public static void sleep(long millis)为当前执行的线程指定睡眠时间。参数millis是线程睡眠的毫秒数。如果这个线程已经被别的线程中断,则产生InterruptedException异常
public static void yield()暂停当前线程的执行,但该线程仍处于就绪状态,不转为阻塞状态。该方法只给同优先级线程以执行的机会

要在一个Thread的子类里激活线程,必须先做好下列两件事情。

  1. 此类必须是继承自Thread类;
  2. 线程所要执行的代码必须写在run()方法内。

线程执行时,从它的run()方法开始执行。run()方法是线程执行的起点,就像main()方法是应用程序的执行起点、init()方法是小程序的执行起点一样。所以必须通过定义run()方法来为线程提供代码。run()是定义在Thread类里的方法,因此把线程的程序代码编写在run()方法内,实际上所做的就是覆盖的操作,因此要使一个类可激活线程,必须使用下列语法来编写:

class 类名 extends Thread{
    类里的成员变量;
    类里的成员方法;
    修饰符 run(){
        线程的代码
    }
}

说明:run()方法规定了线程要执行的任务,但一般不是直接调用run()方法,而是通过线程的start()方法来启动线程。

案例:利用Thread类的子类来创建线程

class MyThread extends Thread{
    private String who;
    public MyThread(String str){//自定义构造方法
        who=str;
    }

    @Override
    public void run() {//重写run()方法
        for (int i = 0; i < 5; i++) {
            try {
                sleep((int)(1000*Math.random()));
            } catch (InterruptedException e) {
                //什么都不用做
            }
            System.out.println(who+"正在运行!");
        }
    }
}
public class Test1 {//文件名必须是public class的名称Test1
    public static void main(String[] args) {
        MyThread thread1 = new MyThread("A");
        MyThread thread2 = new MyThread("B");
        thread1.start();
        thread2.start();
        System.out.println("主方法main()运行结束!");
    }
}
输出结果:
    主方法main()运行结束!
    A正在运行!
    A正在运行!
    A正在运行!
    B正在运行!
    B正在运行!
    B正在运行!
    B正在运行!
    B正在运行!
    A正在运行!
    A正在运行!

注意:sleep()方法里的参数Math.random()将产生01的浮点型随机数,乘以1000后可以变成01000的浮点型随机数,因为sleep()方法的参数必须是整型,所以必须进行强制转换。因此利用该语句可控制线程随机睡眠01000ms(01s)。需要注意的是,由于main()方法本身也是一个线程,它所创建的两个线程优先级和它是一样的,因此执行完两个线程的start()方法“激活”线程后会接着往下执行输出语句。至于会先执行main()线程里的输出语句还是先跳到另外两个线程里去执行run()方法,全看谁先抢到CPU资源。通常情况下是main()线程里的输出语句先执行,因为它不用经过线程激活的过程。

用Runnable接口来创建线程

由于Java语言单继承的缺陷,一个类如果通过继承Thread类来创建线程,就不能再继承其他类了,在这种情况下,可以使用Runnable接口。Runnable接口是Java语言中实现线程的接口,定义在java.lang包中,其中只提供了一个抽象方法run()的声明。从本质上说,任何实现线程的类都必须实现该接口。其实Thread类就是直接继承了Object类,并实现了Runnable接口,所以其子类才具有线程的功能。因此,用户可以声明一个类并实现Runnable接口,并定义run()方法,将线程代码写入其中,就完成了这一部分的任务。但是Runnable接口并没有任何对线程的支持,因此还必须创建Thread类的实例,也就是使用Thread(Runnable target)构造方法,具体方法就是:自己定义一个类实现Runnable接口,然后将这个类所创建的对象作为参数,传递给线程的Thread(Runnable target)构造方法。传递给线程类构造方法的这个对象称为所创建线程的可运行对象(runnable object),当线程调用start()方法激活线程后,轮到它来享用CPU资源时,可运行对象就会自动调用接口中的run()方法,这一过程是自动实现的,用户程序只需要让线程调用start()方法即可。

使用Runnable接口的好处不仅在于它间接地解决了多继承问题,与Thread类相比,Runnable接口更适合用于多个线程处理同一资源。事实上,几乎所有的多线程应用都可以以实现Runnable接口的方式来实现。

案例:利用Runnable接口来创建线程:

class MyThread implements Runnable{
    private String who;
    public MyThread(String str){//自定义构造方法
        who=str;
    }

    @Override
    public void run() {//重写run()方法
        for (int i = 0; i < 5; i++) {
            try {
                Thread.sleep((int)(1000*Math.random()));
            } catch (InterruptedException e) {
                //什么都不用做
            }
            System.out.println(who+"正在运行!");
        }
    }
}
public class Test1 {//文件名必须是public class的名称Test1
    public static void main(String[] args) {
        //创建可运行对象
        MyThread thread1 = new MyThread("A");
        MyThread thread2 = new MyThread("B");
        //利用可运行对象创建线程对象
        Thread t1=new Thread(thread1);
        Thread t2=new Thread(thread2);
        t1.start();
        t2.start();
    }
}

说明:由于这里的MyThread类是由Runnable接口来实现的,所以sleep()方法前要加前缀Thread。

在前面的例子中,可以看出程序中被同时激活的多个线程将同时执行,但有时需要有序地执行,这时可以使用Thread类中的join()方法。当某一线程调用join()方法时,则其他线程会等到该线程结束后才开始执行。也就是说代码t.join()将使t线程“加塞”到当前线程之前获得CPU,当前线程则进入阻塞状态,直到线程t结束为止,当前线程恢复为就绪状态,等待线程调度。

案例:修改前一个案例,使得thread2线程先执行完后,再执行thread1线程,最后再输出字符串"主方法main()运行结束!"。

class MyThread extends Thread{
    private String who;
    public MyThread(String str){//自定义构造方法
        who=str;
    }

    @Override
    public void run() {//重写run()方法
        for (int i = 0; i < 5; i++) {
            try {
                sleep((int)(1000*Math.random()));
            } catch (InterruptedException e) {
                //什么都不用做
            }
            System.out.println(who+"正在运行!");
        }
    }
}
public class Test1 {//文件名必须是public class的名称Test1
    public static void main(String[] args) {
        //创建可运行对象
        MyThread thread1 = new MyThread("A");
        MyThread thread2 = new MyThread("B");
        thread2.start();//激活线程
        try {
            thread2.join();//限制线程结束后才能继续往下执行
        } catch (InterruptedException e) { }
        thread1.start();
        try {
            thread1.join();
        } catch (InterruptedException e) { }
        System.out.println("主方法main()运行结束!");
    }
}
运行结果:
    B正在运行!
    B正在运行!
    B正在运行!
    B正在运行!
    B正在运行!
    A正在运行!
    A正在运行!
    A正在运行!
    A正在运行!
    A正在运行!
    主方法main()运行结束!

解释:该程序激活线程thread2后继续往下执行,但因下面是thread2.join()代码,所以它会使程序的流程先停在此处,直到线程thread2结束之后,才会执行到线程thread1.同理,由于thread1也调用了join()方法,所以要等到线程thread1结束后,才会执行最后的输出语句。

注意:由于join()方法也会抛出InterruptedException类型的异常,所以必须将join()方法放在try-catch块内。

通过上面的介绍,我们知道有两种创建线程对象的方式,这两种方式各有特点。

直接继承Thread类的优点是编写简单,可以直接操纵线程;缺点是若继承Thread类,就不能再继承其他类。

使用Runnable接口的特点是:可以将Thread类与所要处理的任务的类分开,形成清晰的模型;还可以从其他类继承,从而实现多继承的功能。

在程序运行过程中,经常需要通过线程的引用或名字来操作线程,那么如何才能获得当前正在运行的形成呢?分两种情况来说明:第一种情况是若直接使用继承Thread类的子类,在类中this即指当前线程;第二种情况是使用实现Runnable接口的类,要在此类中获得当前线程的引用,必须使用Thread.currentThread()方法。具体地说:

  1. 当可运行对象包含线程对象时,即线程对象是可运行对象的成员时,则在run()方法中可以通过调用Thread.currentThread()方法来获得正在运行的线程的引用。
  2. 当可运行对象不包含线程对象时,在可运行对象run()方法中需要使用代码Thread.currentThread().getName()来返回当前正在运行线程的名字。

线程间的数据共享

同一进程的多个线程间可以共享相同的内存单元,并可利用这些共享单元来实现数据交换、实时通信和必要的同步操作。对于利用构造方法Thread(Runnable target)这种方式创建的线程,当轮到它来享用CPU资源时,可运行对象target就会自动调用接口中的run()方法,因此,对于同一可运行对象的多个线程,可运行对象的成员变量自然就是这些线程共享的数据单元。另外,创建可运行对象的类在需要时还可以是某个特定类的子类,因此,使用Runnable接口比使用Thread的子类更具有灵活性。

通过前面的介绍,我们知道建立Thread子类和实现Runnable接口都可以创建多线程,但它们的主要区别就在于对数据的共享上。使用Runnable接口可以轻松实现多个线程共享相同数据,只要用同一个可运行对象作为参数创建多个线程就可以了。

案例:分别使用两种方式创建多线程,实现三个窗口出售10张车票,出售车票以输出字符串"第x售票窗口出售了第x张车票"的方式表示。(代码略)

提示:只有Runnable接口的实现方式才可以共享资源,三个窗口出售同一叠车票。

彩蛋:可以多运行几次,会发现车票号码出现了bug,下面还会有专门的“bug案例”。

多线程的同步控制

在前面所介绍的线程中,线程功能简单,每个线程都包含了运行时所需要的数据和方法。这样的线程在运行时,因不需要外部的数据和方法,就不必关心其他线程的状态或行为,我们称这样的线程为独立的、不同步的或是异步执行的。当应用问题的功能增强、关系复杂,存在多个线程共享数据时,若线程仍以异步方式访问共享数据,有时是不安全或不符合逻辑的。此时,当一个线程对共享的数据进行操作时,应使之成为一个“原子操作”,即在没有完成相关操作之前,不允许其他线程打断它,否则就会破坏数据的完整性,必然会得到错误的处理结果(比如前一个案例的bug),这就是线程的同步。同步与共享数据是有区别的,共享是指线程之间对内存数据的共享,因为线程共同拥有对内存空间中数据的处理权力,这样会导致因为多个线程同时处理数据而使数据出现不一致,所以我们要提出同步来解决这个问题,即同步是在共享的基础之上,是针对多个线程共享会导致数据不一致而提出来的。那么是否可以利用Runnable接口解决同步问题?也不可以。因为线程可能处于阻塞,一旦出现阻塞状态,CPU就会交给其他线程,其他线程就会对内存数据进行修改。所以说被多个线程共享的数据在同一时刻只允许一个线程处于操作之中,这就是同步控制中的“线程间互斥”问题。

这与前面介绍的并发多线程并不矛盾,这里同步指的是处理数据的线程不能处理其他线程当前还没有处理结束的数据,但是可以处理其他的数据。由此可知,如果对共享数据处理不当就可能会造成程序运行的不确定性和其他错误。下面用多线程来模拟两个用户从银行取款的操作,它可能会造成数据混乱。

案例:设某银行账户存款额的初值是2000元,用线程模拟两个用户分别从银行取款的情况。两个用户分四次分别从银行的同一账户取款,每次取出100元。

class Bank{//模拟银行账户类
    private static int sum=2000;
    public static void take(int k){
        int temp=sum;
        temp-=k;
        try {
            Thread.sleep((int)(1000*Math.random()));
        } catch (InterruptedException e) { }
        sum=temp;
        System.out.println("sum="+sum);
    }
}
class Customer extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            Bank.take(100);
        }
    }
}
public class Test{
    public static void main(String[] args) {
        Customer customer1=new Customer();
        Customer customer2=new Customer();
        customer1.start();
        customer2.start();
    }
}
运行结果:
    sum=1900
    sum=1800
    sum=1900
    sum=1700
    sum=1800
    sum=1600
    sum=1700
    sum=1600

用户现有存款额sum的初值是2000元,如果每个用户各取出400元,存款额的最后余额应该是1200元。但程序的运行结果并非如此,而且运行结果是随机的,每次可能互不相同。

之所以会出现这种结果,是由于线程c1和c2的并发运行引起的。例如,当c1从存款额sum中取出100元,c1中的临时变量temp的初始值是2000,则将temp的值改变为1900,在将temp的新值写回sum之前,c1休眠了一段时间。正在c1休眠的这段时间内,c2来读取sum的值,其值仍然是2000,然后将temp的值改变为1900,在将temp的新值写回sum之前,c2休眠了一段时间。这时,c1休眠结束,将sum更改为其temp的值1900,并输出1900。接着进行下一轮循环,将sum的值改为1800,并输出后再继续循环,在将temp的值改变为1700之后,还未来得及将temp的新值写回sum之前,c1进入休眠状态。这时,c2休眠结束,将它的temp的值1900写入sum中,并输出sum的现在值1900.如此继续,知道每个线程结束,出现了和原来设想不相符的结果。

该程序出现错误结果的原因是两个并发线程共享同一内存变量所引起的。后一线程对变量的更改结果覆盖了前一线程对变量的更改结果,造成数据混乱。通过分析上面的例子发现,上述错误是因为在线程执行过程中,在执行有关的若干个动作时,没有能够保证独占相关的资源,而是在对该资源进行处理时又被其他线程的操作打断或干扰而引起的。因此,要防止这样的情况发生,就必须保证线程在一个完整的操作所有动作的执行过程中,都占有相关资源而不被打断,这就是线程同步的概念。

在并发程序设计中,对多线程共享的资源或数据称为临界资源同步资源,而把每个线程中访问临界资源的那一段代码称为临界代码临界区。简单地说,在一个时刻只能被一个线程访问的资源就是临界资源,而访问临界资源的那段代码就是临界区。临界区必须互斥地使用,即一个线程执行临界区中的代码时,其他线程不准进入临界区,直至该线程退出为止。为了使临界代码对临界资源的访问成为一个不可被中断的原子操作,Java技术利用对象“互斥锁”机制来实现线程间的互斥操作。在Java语言中,每个对象都有一个“互斥锁”与之相连。当线程A获得了一个对象的互斥锁后,线程B若也想获得该对象的互斥锁,就必须等待线程A完成规定的操作并释放出互斥锁后,才能获得该对象的互斥锁,并执行线程B中的操作。一个对象的互斥锁只有一个,所以利用对一个对象的互斥锁的争夺,可以实现不同线程的互斥效果。当一个线程获得互斥锁后,则需要该互斥锁的其他线程只能处于等待状态。在编写多线程的程序时,利用这种互斥锁机制,就可以实现不同线程间的互斥操作。

为了保证互斥,Java语言使用synchronized关键字来标识同步的资源,这里的资源可以是一种类型的数据,也就是对象,也可以是一个方法,还可以是一段代码。Synchronized直译为同步,但实际指的是互斥。synchronized的用法如下:

格式一:同步语句

Synchronized(对象){
    临界代码段
}

其中,“对象”是多个线程共同操作的公共变量,即需要锁定的临界资源,它将被互斥地使用。

格式二:同步方法

public synchronized 返回类型 方法名(){
    方法体
}

同步方法的等效方式如下:

public 返回类型 方法名(){
    synchronized(this){
        方法体
    }
}

synchronized的功能是:线程首先判断对象或方法的互斥锁是否存在,若存在就获得互斥锁,然后就可以执行紧随其后的临界代码段或方法体;如果对象或方法的互斥锁不存在(已被其他线程拿走),线程就进入等待状态,直到获得互斥锁。

注意:当被synchronized限定的代码段执行完,就自动释放互斥锁。

案例:修改上述案例,用线程同步的方法设计用户从银行取款的应用程序。

class Bank{//模拟银行账户类
    private static int sum=2000;
    public synchronized static void take(int k){//仅仅是添加了synchronized修饰符
        int temp=sum;
        temp-=k;
        try {
            Thread.sleep((int)(1000*Math.random()));
        } catch (InterruptedException e) { }
        sum=temp;
        System.out.println("sum="+sum);
    }
}
class Customer extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 4; i++) {
            Bank.take(100);
        }
    }
}
public class Test{
    public static void main(String[] args) {
        Customer customer1=new Customer();
        Customer customer2=new Customer();
        customer1.start();
        customer2.start();
    }
}
输出结果:
    sum=1900
    sum=1800
    sum=1700
    sum=1600
    sum=1500
    sum=1400
    sum=1300
    sum=1200

这里仅将take()方法用synchronized关键字修饰成线程同步方法。由于对take()方法增加了同步限制,所以在线程customer1结束take()方法运行之前,线程customer2无法进入take()方法。同理,在线程customer2结束take()方法运行之前,线程customer1无法进入take()方法,从而避免了一个线程对sum变量的修改结果覆盖另一个线程对sum变量的修改结果的情况发生。

下面对synchronized做进一步说明:

  1. synchronized锁定的通常是临界代码。由于所有锁定同一个临界代码的线程之间在synchronized代码块上是互斥的,也就是说,这些线程的synchronized代码块之间是串行执行的,不再是互相交替穿插并发执行,因而保证了synchronized代码块操作的原子性。
  2. synchronized代码块中的代码数量越少越好,包含的范围越小越好,否则就会失去多线程并发执行的很多优势。
  3. 若两个或多个线程锁定的不是同一个对象,则它们的synchronized代码块可以互相交替穿插并发执行。
  4. 所有的非synchronized代码块或方法,都可自由调用。如线程A获得了对象的互斥锁,调用对象的synchronized代码块,其他线程仍然可以自由调用该对象的所有非synchronized方法和代码。
  5. 任何时刻,一个对象的互斥锁只能被一个线程所拥有。
  6. 只有当一个线程执行完它所调用对象的所有synchronized代码块或方法时,该线程才会释放这个对象的互斥锁。
  7. 临界代码中的共享变量应定义为private型。否则,其他类的方法可能直接访问和操作该共享变量,这样synchronized的保护就失去了意义。
  8. 由于上一条的原因,只能用临界代码中的方法访问该共享变量。故锁定的对象通常是this,即通常格式都是:synchronized(this){…}。
  9. 一定要保证,所有对临界代码中共享变量的访问与操作均在synchronized代码块中进行。
  10. 对于一个static型的方法,即类方法,要么整个方法是synchronized,要么整个方法不是synchronized。
  11. 如果synchronized用在类声明中,则表示该类中的所有方法都是synchronized的。

线程之间的通信

多线程的执行往往需要相互之间的配合。为了更有效地协调不同线程的工作,需要在线程间建立沟通渠道,通过线程间的“对话”来解决线程间的同步问题,而不仅仅是依靠互斥机制。例如,当一个人在排队买面包时,若她给收银员的不是零钱,而收银员又没有零钱找给她,那她就必须等待,并允许她后面的人先买,以便收银员获得零钱找给她。如果她后面的这个人仍然没有零钱,那么她俩就都必须等待,并允许后面的人先买。

java.lang.Object类的wait()、notify()和notifyAll()等方法为线程间的通信提供了有效手段。如下:

Object类中用于线程间通信的常用方法功能说明
public final void wait()如果一个正在执行同步代码(synchronized)的线程A执行了wait()调用(在对象x上),该线程暂停执行而进入对象x的等待队列,并释放已获得的对象x的互斥锁。线程A要一直等到其他线程在对象x上调用notify()或notifyAll()方法,才能够在重新获得对象x的互斥锁后继续执行wait()代码后的内容
public void notify()唤醒正在等待该对象互斥锁的第一个线程
public void notifyAll()唤醒正在等待该对象互斥锁的所有线程,具有最高优先级的线程首先被唤醒并执行

注意:对于一个线程,若基于对象想调用wait()、notify()或notifyAll()方法,该线程必须已经获得对象x的互斥锁。换句话说,wait()、notify()和notifyAll()只能在同步代码块里调用。

需要说明的是,sleep()方法和wait()方法一样,都能使得线程阻塞,但这两个方法是有区别的,wait()方法在放弃CPU资源的同时交出了资源的控制权,而sleep()方法则无法做到这一点。

当一个线程使用的同步方法中用到某个变量,而该变量又需要其他线程修改后才能符合本线程的需要时,可以在同步方法中使用wait()方法。wait()方法将中断线程的执行,暂时让出CPU的使用权,使本线程转入阻塞状态,并允许其他线程使用这个同步方法。当调用wait()方法的线程所需的条件满足后,应确保其他线程会调用notify()或notifyAll()方法通知一个或所有由于使用这个同步方法而处于阻塞状态的线程结束等待,进入就绪状态,曾中断的线程就会从刚才的中断处继续执行这个同步方法。

案例:用两个线程模拟存票、售票过程,但要求每存入一张票,就售出一张票,售出后,再存入,直至售完为止。

public class Test{
    public static void main(String[] args) {
        Tickets tickets=new Tickets(10);
        new Producer(tickets).start();
        new Consumer(tickets).start();
    }
}

class Tickets{
    protected int size;//总票数
    int number=1;//票号
    boolean available=false;//是否有票可售
    public Tickets(int size){//构造方法,传入总票数参数
        this.size=size;
    }
    public synchronized void put(){//同步方法,实现存票功能
        if(available){//如果还有存票,存票线程就等待
            try { wait(); } catch (InterruptedException e) { }
        }
        System.out.println("存入第【"+number+"】号票");//等待完毕,存票
        available=true;//有票可售
        notify();//唤醒售票线程
    }
    public synchronized void sell(){
        if(!available){
            try { wait(); } catch (InterruptedException e) { }
        }
        System.out.println("售出第【"+number+"】号票");
        number++;
        available=false;
        notify();
    }
}

class Producer extends Thread{
    Tickets t=null;
    public Producer(Tickets t){//构造方法,使两线程共享Tickets对象
        this.t=t;
    }

    @Override
    public void run() {
        while(t.number<t.size)
            t.put();
    }
}

class Consumer extends Thread{
    Tickets t=null;
    public Consumer(Tickets t){
        this.t=t;
    }

    @Override
    public void run() {
        while(t.number<=t.size)
            t.sell();
    }
}
输出结果:
    存入第【1】号票
    售出第【1】号票
    存入第【2】号票
    售出第【2】号票
    存入第【3】号票
    售出第【3】号票
    存入第【4】号票
    售出第【4】号票
    存入第【5】号票
    售出第【5】号票
    存入第【6】号票
    售出第【6】号票
    存入第【7】号票
    售出第【7】号票
    存入第【8】号票
    售出第【8】号票
    存入第【9】号票
    售出第【9】号票
    存入第【10】号票
    售出第【10】号票

本章小结

  1. 线程(thread)是指程序的运行流程。多线程机制可以同时运行多个程序块,使程序运行的效率变得更高,也可以克服传统程序语言所无法涉及的问题。
  2. 多任务与多线程是两个不同的概念。多任务是针对操作系统而言的,表示操作系统可以同时运行多个应用程序;而多线程是针对一个程序而言的,表示在一个程序内部可以同时执行多个线程。
  3. 创建线程有两种方法:一种是继承java.lang包中的Thread类;另一种是用户在定义自己的类中实现Runnable接口。
  4. run()方法给出了线程要执行的任务。若是派生自Thread类,必须把线程的程序代码编写在run()方法内,实现覆盖操作;若是实现Runnable接口,必须在实现Runnable接口的类中定义run()方法。
  5. 如果在类中要激活线程,必须先做好下列两件事情:1.此类必须是派生自Thread类或实现Runnable接口,使自己成为它的子类;2.线程的任务必须写在run()方法内。
  6. 每一个线程,在其创建和消亡之前,均会处于下列五中状态之一:新建状态、就绪状态、运行状态、阻塞状态和消亡状态。
  7. 阻塞状态的线程一般情况下可由下列情况所产生:1.该线程调用对象的wait()方法;2.该线程本身调用了sleep()方法;3.该线程和另一个线程join()在一起;4.有优先级更高的线程处于就绪状态。
  8. 解除阻塞的原因有:1.如果线程是由调用对象的wait()方法所阻塞的,则该对象的notify()方法被调用时可解除阻塞;2.线程进入睡眠(sleep)状态,但指定的睡眠时间到了。
  9. Thread类中的sleep()方法可以用来控制线程睡眠时间,睡眠时间的长短全由sleep()方法中的参数而定,单位为1/1000s。
  10. 线程在运行时,因不需要外部的数据或方法,就不必关心其他线程的状态或行为,这样的线程称为独立、不同步的或是异步执行的。
  11. 被多个线程共享的数据在同一时刻只允许一个线程处于操作之中,这就是同步控制。
  12. 当一个线程对共享的数据进行操作时,在没有完成相关操作之前,应使之成为一个“原子操作”,即不允许其他线程打断它,否则可能会破坏数据的完整性,而得到错误的处理结果。
  13. synchronized锁定的是一个具体对象,通常是临界区对象。所有锁定同一个对象的线程之间,在synchronized代码块上是互斥的,也就是说,这些线程的synchronized代码块之间是串行执行的,不再是互相交替穿插并发执行,因而保证了synchronized代码块操作的原子性。
  14. 由于所有锁定同一个对象的线程之间,在synchronized代码块上是互斥的,这些线程的synchronized代码块之间是串行执行的,故synchronized代码块中的代码数量越少越好,包含的范围越小越好,否则多线程就会失去很多并发执行的优势。
  15. 任何时刻,一个对象的互斥锁只能被一个线程所拥有。
  16. 只有当一个线程执行完它所调用对象的所有synchronized代码块或方法时,该线程才会自动释放这个对象的互斥锁。
  17. 一定要保证,所有对临界区共享变量的访问与操作均在synchronized代码块中进行。

课后习题

  1. 假设某家银行可接受顾客的存款,每进行一次存款,便可计算出存款的总额。现有两名顾客,每人分三次,每次存入100元钱。试编程来模拟顾客的存款操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值