第十三章、多线程


进程是指运行中的应用程序,每一个进程都有自己独立的内存空间,对一个应用程序可以同时启动多个进程。线程是指进程中的一个执行流程,有时也称为执行情景。一个进程可以由多个线程组成,即在一个进程中可以同时运行多个不同的线程,它们分别执行不同的任务。当进程内的多个线程同时运行时,这种运行方式称为并发运行。

线程与进程的主要区别在于:每个进程都需要操作系统为其分配独立的内存地址空间,而同一进程中的所有线程在同一块地址空 间中工作,这些线程可以共享同一块内存和系统资源。

13.1 Java 线程的运行机制

在 Java 虚拟机进程中,执行程序代码的任务是由线程来完成的。每个线程都有一个独立的程序计数器和方法调用栈(method invocation stack):

  • 程序计数器:也称为 PC 寄存器,当线桯执行一个方法时,程序计数器指向方法区中下一条要执行的字节码指令。
  • 方法调用栈:简称方法栈,用来跟踪线程运行中一系列的方法调用过程,栈中的元素称为栈桢。每当线程调用一个方法时,就会向方法栈压入一个新桢,桢用来存储方法的参数、局部变量和运算过程中的临时数据。

栈桢由 3 部分组成:

  • 局部变量区:存放局部变量和方法参数 。
  • 操作数栈:是线程的工作区,用来存放运算过程中生成的临时数据。
  • 栈数据区:为线程执行指令提供相关的信息,包括:如何定位到位于堆区和方法区的特定数据,如何正常退出方法或者异常中断方法。

每当用 java 命令启动一个 Java 虚拟机进程时,Java 虚拟机就会创建一个主线程,该线程从程序入口 main()方法开始执行。

public class Sample { 
    private int a;              //实例变量
    public int method() { 
        int b = 0;              //局部变量
        a++; 
        b = a; 
        return b;
    } 
    public static void main(String[] args) { 
        Sample s = null;        //局部变量
        int a = 0;              //局部变量
        s = new Sample(); 
        a = s.method(); 
        System.out.println(a);
    }
}
/*
当线程开始执行 method() 方法的 a++ 操作时,运行时数据区的状态如下

 ____Java栈区_____   _______堆区_____    _________方法区________
|method方法的栈帧 |  |               |  |                       |
|   ·局部变量a    |  |               |  |   Sample类的数据结构   |   
|main方法的栈帧   |  |               |  |   ·method方法的字节码  |
|   ·局部变量s  --|--|-->Sample对象--|--|--->   ·(具体指令) <--|---主线程程序计数器
|   ·局部变量a    |  |   ·实例变量a   | |                       |

*/ 

当主线程执行 a++ 操作时,它能根据 method() 方法的栈桢的栈数据区中的有关信息,正确地定位到堆区的 Sample 对象的实例变量 a, 把它的值加 1。当 method() 方法执行完毕后,它的栈桢就会从方法栈中弹出,它的局部变量 b 结束生命周期。main() 方法的栈桢成为当前桢,主线程继续执行 main() 方法。

方法区存放了线程所执行的字节码指令,堆区存放了线程所操作的数据(以对象的形式存放),Java 栈区则是线程的工作区,保存线程的运行状态。

13.2 线程的创建和启动

Java 虚拟机的主线程,它从启动类的 main() 方法开始运行。此外,用户还可以创建自己的线程,它将和主线程并发运行。创建线程有两种方式:

扩展 java.lang.Thread 类

Thread 类代表线程类,它的最主要的两个方法是:

  • run():包含线程运行时所执行的代码。
  • start():用于启动线程。

用户的线程类只需继承 Thread 类,覆盖 Thread 类的 run() 方法。在 Thread 类中,run() 方法的定义如下:public void run()。该方法没有声明抛出任何异常,根据方法覆盖的规则,Thread 子类的 run() 方法也不能声明抛出任何异常。

  1. 主线程于用户自定义的线程并发运行
    package allrun;
    public class Machine extends Thread {
        public void run() {
            for (int i = 0; i < 50; i++) {
                //Thread 类的 currentThread() 静态方法返回当前线程的引用
                //Thread 类的 getName()实例方法则返回线程的名字 
                // 相当于 Thread thread = Thread.currentThread();
                //        String name = Thread.getName();
                System.out.println(currentThread().getName() + i);
                try {
                    sleep(100);                         //给其他线程运行机会
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public static void main(String[] args) {
            Machine m1 = new Machine();
            Machine m2 = new Machine();
            m1.start();                                 //启动线程
            m2.start();
            m1.run();                                   //主线程执行m1对象的run()方法
        }
    }
    
  2. 多个线程共享同一个对象的实例变量
    package sharevar;
    public class Machine extends Thread {
        private int i = 0;                      //实例变量
        public void run() {
            for (i = 0; i < 50; i++) {
                System.out.println(currentThread().getName() + i);
                try {
                    sleep(100);                         
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public static void main(String[] args) {
            
            //主线程和 Machine 线程并发执行 Machine 对象的 run() 方法,操作同一个实例变量轮流加1
            Machine machine = new Machine();
            machine.start();                            //启动一个Machine线程
            m1.run();                                   //主线程执行run()方法
    
            /** 若修改方法:           
            Machine m1 = new Machine();
            Machine m2 = new Machine();
            m1.start();
            m2.start();
            实例方法和静态方法的字节码都位于方法区,被所有的线程共享。
            意味着当 m1 线程执行 run()方法时,会把 run() 方法中的变量 i 解析为 m1 对象的实例变量 
            同理当 m2 线程执行 run()方法时,会把 run() 方法中的变量 i 解析为 m2 对象的实例变量
            即 m1 m2 线程分别操作不同的实例变量 i
             */
        }
    }
    
  3. 不要随便覆盖 Thread 类的 start() 方法
    创建了一个线程对象后,线程并不自动开始运行,必须调用它的 start() 方法才能启动线程。JDK 为 Thread 类的 start() 方法提供了默认的实现。当用 new 语句创建 Machine 对象时,仅仅在堆区内出现一个新的对象但 Machine 线程并没有被启动。当主线程执行 Machine 对象的 start() 方法时才会启动线程,并在 Java 栈区为其创建相应的方法调用栈。
    package wrongstart;
    public class Machine extends Thread {
        private int i = 0;
        public void start() {                       //覆盖start()方法
            run();                              
        }
        public void run() {
            for (int i = 0; i < 50; i++) {
                System.out.println(currentThread().getName() + i);
                try {
                    sleep(100);                         
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public static void main(String[] args) {
            Machine machine = new Machine();
            machine.start();
            //当主线程执行 machine.start(), start() 方法并没有启动一个新的 Machine 线程,
            //而是去调用 Machine 对象的 run() 方法,这只是普通的方法调用 
            //即 Machine 对象的 run() 方法是由主线程执行的
            //打印结果: main:0 main:1 ... main:49
            //而非:thread0:1 thread0:2 ...thread0:49
        }
    }
    
    故在 Thread 子类中不应该随意覆盖 start() 方法,假如一定要覆盖 start() 方法,那么应该先调用super.start() 方法。
    package correctstart;
    public class Machine extends Thread {
        private int i = 0;
        private static int count = 0;           //统计被启动的 Machine 线程的数目
        public void start() {                       
            super.run();                              
            System.out.println(currentThread().getName() + (++count) + "isStart");
            //本行代码由主线程执行
        }
        public void run() {
            for (int i = 0; i < 50; i++) {
                System.out.println(currentThread().getName() + i);
                try {
                    sleep(100);                         
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        public static void main(String[] args) {
            Machine m1 = new Machine();
            Machine m2 = new Machine();
            m1.start();                                
            m2.start();
        }
    }
    
  4. 一个线程只能被启动一次
    Machine m = new Machine();
    m.start();                                
    m.start();      //第二次调用时会抛出 Java.lang.IllegalThreadStateException 异常
    

实现 Runnable 接口

Java 不允许一个类继承多个类,因此一旦一个类继承了 Thread 类,就不能再继承其他的类。为了解决这一问题,Java 提供了 java.lang.Runnable 接口,它有一个 run() 方法,它的定义如下:public void run()

package runimpl;
public class Machine implements Runnable {
    private int i = 0;
    public void run() {
        for (int i = 0; i < 50; i++) {
            System.out.println(currentThread().getName() + i);
            try {
                sleep(100);                         
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
    public static void main(String[] args) {
        //主线程创建的 t1 和 t2 两个线程对象启动后都会执行 machine 变量所引用的 Machine 对象的 run() 方法 
        //t1 和 t2 共享同一个 machine对象,因此在执行 run() 方法时操纵同一个实例变量 i
        Machine machine = new Machine();
        Thread t1 = new Thread(machine);
        Thread t2 = new Thread(machine);
        t1.start();
        t2.start();

         /** 若修改方法:           
        Machine m1 = new Machine();
        Machine m2 = new Machine();
        Thread t1 = new Thread(m1);
        Thread t2 = new Thread(m2);
        t1.start();
        t2.start();
        启动 t1 和 t2 线程后,将分别执行 machine1 和 machine2 变量各自引用的 Machine 对象的 run() 方法
        因此 t1 和 t2 线程操纵不同的 Machine 对象的实例变量 i
        */
    }
}

在 Thread 类中定义了如下形式的构造方法:

Thread(Runnable runnble)            //当线程启动时,会执行参数 runnable 所引用对象的 run() 方法

13.3 线程的状态转换

线程在它的生命周期中会处于各种不同的状态,

  1. 新建状态
    用 new 语句创建的线程对象处于新建状态(New),此时它和其他Java对象一样仅仅在堆区内被分配了内存。
  2. 就绪状态
    当一个线程对象创建后,其他线程调用其 start() 方法,该线程进入就绪状态(Runnable),Java 虚拟机为其创建方法调用栈和程序计数器。处于该状态的线程位于可运行池中,等待CPU的使用权。、
  3. 运行状态
    处于运行状态(Running) 的线程占用 CPU,执行程序代码。在并发运行环境中,如果计算机只有一个 CPU, 那么任何时刻只会有一个线程处于这个状态。如果计算机有多个 CPU, 那么同一时刻可以让几个线程占用不同的 CPU, 使它们都处于运行状态。只有处于就绪状态的线程才有机会转到运行状态。
  4. 阻塞状态
    阻塞状态(Blocked)是指线程因为某些原因放弃 CPU,暂时停止运行。当线程处于阻塞状态时,Java 虚拟机不会给线程分配 CPU, 直到线程重新进入就绪状态,它才有机会转到运行状态。
    阻塞状态可分为 3 种:
    • 位于对象等待池中的阻塞状态(Blocked in object’s wait pool):当线程处于运行状态时,如果执行了某个对象的 wait() 方法,Java 虚拟机就会把线程放到这个对象的等待池中。
    • 位于对象锁池中的阻塞状态( Blocked in object’s lock pool):当线程处于运行状态,试图获得某个对象的 同步锁 时,如果该对象的同步锁已经被其他线程占用,Java 虚拟机就会把这个线程放到这个对象的锁池中。
    • 其他阻塞状态(Otherwise Blocked):当前线程执行了 sleep() 方法,或者调用了其他线程的 join() 方法,或者发出了 I/O 请求时,就会进入这个状态。
    //主线程启动一个 Machine 线程后,就等待用户的标准输入,主线程进入阻塞状态, 
    //Machine 线程占用 CPU 继续运行。直到用户输入数据,主线程才会恢复运行 
    package waitio; 
    public class Machine extends Thread { 
        private static StringBuffer log = new StringBuffer(); 
        private static int count = 0; 
        public void run() { 
            for(int i = 0; i < 50; i++)  
                System.out.println(currentThread().getName() + i); 
        }        
    public static void main(String[] args) throws Exception { 
        Machine machine = new Machine(); 
        machine.start(); 
        //主线程进入阻塞状态,等待用户的输入,直到获得用户输入的数据,才退出阻塞
        int data = System.in.read(); 
        machine.run();
    }
    
  5. 死亡状态
    当线程退出 run() 方法以后,就进入死亡状态(Dead),该线程结束生命周期。线程有可能是正常执行完 run() 方法而退出的,也有可能是遇到异常而退出的。不管线程是正常结束还是异常结束,都不会对其他线程造成影响。
    package withex;
    public class Machine extends Thread { 
        public void run() { 
            for(int i = 0; i < 3; i++) {
                System.out.println(currentThread().getName() + i); 
                if (i ==1 && currentThread().getName().equals("m1")) 
                    throw new RuntimeException("Wrong from Machine");
                try{ 
                    sleep(100); 
                } catch(InterruptedException e) {
                    throw new RuntimeException(e);
                } 
            }
        } 
        public static void main(String[] args) throws Exception{ 
            Machine machine = new Machine(); 
            machine.setName("m1"); 
            machine.start(); 
            machine.run(); 
    
            //Thread 类的 isAlive() 方法判断一个线程是否活着,当线程处于死亡状态或者新建状态时返回false
            System.out.println("Is machine alive: "+ machine.isAlive()); 
            System.out.println("main:end"); 
        }
    }
    

13.4 线程调度

计算机通常只有 一个 CPU,在任意时刻只能执行一条机器指令,每个线程只有获得 CPU 的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线和轮流获得 CPU 的使用权,分别执行各自的任务。在可运行池中,会有多个处于就绪状态的线程在等待CPU,Java 虚拟机的一项任务就是负责线程的调度。线程的调度是指按照特定的机制为多个线和分配 CPU 的 使用权,有两种调度模型:分时调度模型和抢占式调度模型。

分时调度模型是指让所有线程轮流获得 CPU 的使用权, 并且平均分配每个线程占用CPU的时间片。Java 虚拟机采用抢占式调度栈型,它是指优先让可运行池中优先级高的线程占用CPU, 如果可运行池中线程的优先级相同,那么就随机地选择一个线程,使其占用 CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。值得注意的是,线程的调度不是是跨平台的,它不仅取决于 Java 虚拟机,还依赖于操作系统。

Java 线桯的调度不是分时的,同时启动多个线程后,不能保证各个线程轮流获得均等的 CPU 时间片。如果希望明确的让一个线程给另一个线程运行的机会,可以采用一下方法:

  • 调整各个线程的优先级。
  • 让处于运行状态的线程调用 Thread.sleep()方法。
  • 让处于运行状态的线程调用 Thread.yield()方法。
  • 让处于运行状态的线程调用另-个线程的 join()方法。

调整优先级

所有处于就序状态的线程根据优先级存放在可运行池中,优先级低的线桯获得较少的运行机会,优先级高的线程获得较多的运行机会。Thread 类的 setPriority(int i)getPriority() 方法分别用来设置优先级和读取优先级。优先级用整数表示,取值范围是1~10, Thread 类有 3 个静态常量:

  • MAX_PRIORITY: 取值为 10, 表示最高优先级。
  • MIN_PRIORITY: 取值为 1, 表示最低优先级。
  • NORM_PRIORITY: 取值为 5, 表示默认的优先级。

线程睡眠

当一个线程在运行中执行了 sleep() 方法,它就会放弃 CPU,转到阻塞状态。Thread 类的 sleep(long millis) 方法是静态的,millis 参数设定睡眠的时间,以亳秒为单位。当线程结束睡眠时,首先转到就绪状态,假如存在另一线桯正在运行,该线程不一定会立即运行,而是在可运行池中等待获得 CPU。线程在睡眠时如果被中断,就会收到一个 InterruptedException 异常,线程跳到异常处理代码块。

// Sleeper 类的 main() 方法中,主线程启动 Sleeper 线程, Sleeper 线程睡眠 1 分钟,
// 主线程睡眠 10 毫秒后中断 Sleeper 线程的睡眠
public class Sleeper extends Thread{ 
    public void run() { 
        try{ 
            sleep(60000);                   // 睡眠 l 分钟
            System.out.println("sleep over"); 
        } catch (InterruptedException e){ 
            System.out.println("sleep interrupted"); 
        } 
        System.out.println("end"); 
    }
    public static void main(String[] args) throws Exception{ 
        Sleeper sleeper = new Sleeper(); 
        sleeper.start();
        Thread.sleep(10); 
        sleeper.interrupt();                //中断 Sleeper 线程的睡眠
    }
} 

线程让步

当线程在运行中执行了 Thread 类的 yield() 静态方法时,如果此时具有相同优先级的其他线程处于就绪状态,yield() 方法将把当前运行的线程放到可运行池中并使另一个线程运行。如果没有相同优先级的可运行进程, yield() 方法什么都不做。

public void run() {
    for (int i = 0; i < 20; i++) {
        log.append(currentThread().getName() + i);
        if (++ count % 10 == 0) log.append("\n");
        yeild();
    }
}

sleep() 方法和 yield() 方法都是 Thread 类的静态方法,都会使当前处于运行状态的线程放弃 CPU,把运行机会让给别的线程。 两者的 区别 在于:

  • sleep() 方法会给其他线程运行的机会,不考虑其他线程的优先级,因此会给较低优先级线程一个运行的机会;yield() 方法只会给相同优先级或者更高优先级的线程一个运行的机会。
  • 当线程执行了 sleep() 方法以后,将转到阻塞状态,参数 millis 指定睡眠时间;当线程执行了 yield() 方法以后,将转到就绪状态。
  • sleep() 方法声明抛出 InterruptedException 异常,而 yield() 方法没有声明抛出任何异常。
  • sleep() 方法比 yield() 方法具有更好的可移植性。不能依靠 yield() 方法来提高程序的并发行能。对于大多数程序员来说,yield() 方法的唯一用途是在测试期间入为地提高程序的并发性能,以帮助发现一些隐藏的错误。

等待其他线程结束

当前运行的线程可以调用另一个线程的 join() 方法,当前运行的线程将转到阻塞状态直至另一个线程运行结束,它才会恢复运行。

package join;
public class Machine extends Thread {
    public void run() { 
        for(int i = 0; i < 50; i++) 
            System.out.println(getName() + i); 
    } 
    public static void main(String[] args) throws Exception { 
        Machine machine = new Machine(); 
        machine.setName("m1"); 
        machine.start(); 
        System.out.println("main:join machine"); 
        machine.join();                 //主线程等待 machine 线程运行结束
        System.out.println("main:end"); 
    }  
}

join() 方法有两种重载形式:

public void join() 
public void join(long timeout)       //timeout 参数设定当前线程被阻塞的时间,以毫秒为单位

13.5 获得当前线程对象的引用

Thread 类的 currentThread() 静态方法返回当前线程对象的引用。

package threadref; 
public class Machine extends Thread { 
    public void run() { 
        for(int i = 0; i < 3; i++) {
            System.out.println(currentThread().getName() + i);

            /** 
            打印结果 main:0 m1:0 main:1 m1:1 main:2 m1:2
            
            如果修改 currentThread() 为 this
            System.out.println(this.getName() + i);
            则打印结果:m1:0 m10 m1:1 m1:1 m1:2 m1:2
             */ 
            
            yield(); 
        }
    }
    public static void main(String[] args) throws Exception { 
        Machine machine = new Machine(); 
        machine.setName("m1"); 
        machine.start();                //启动线程m1
        machine.run();                  //主线程普通调用run()方法
    }
} 

不管是主线程还是 machine 线程,都执行 machine 对象的 run() 方法, run() 方法中的 this 关键字引用当前的 machine 对象,因此 this.getName() 方法总是返回 machine 对象的 name 属性。

13.6 后台线程

后台线程的特点是:后台线程与前台线程相伴相随,只有当所有前台线程结束生命周期后,后台线程才会结束生命周期。只要有一个前台线程还没有运行结束,后台线程就不会结束生命周期。

// Machine 线程是前台线程,负责把它的实例变量 a 的值不断地加 1
// 在 Machine 线程的 start() 方法中的匿名线程类的实例作为后台线程,定期把 Machine 对象的实例变量归零
package withdaemon;
public class Machine extends Thread {
    private int a;
    private static int count;
    public void start() {
        super.start();
        Thread deamon = new Thread() {                  //匿名线程类
            public void run() {
                while(true) {
                    reset();
                    try {
                        sleep(50);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        deamon.setDaemon(true);           //Thread 类的 setDaemon(true) 方法设置线程为后台线程
        deamon.start();
    }
    public void reset() {
        a = 0;
    }
    public void run() {
        while (true) {
            System.out.println(getName() + i++);
            if (count++ == 100) break;
            yield();
        }
    }
    public static void main(String[] args) throws Exception {
        Machine m = new Machine();
        m.start();
    }
}

尽管以上匿名的后台线程的 run() 方法执行的是无限循环,但只要其他前台线程都运行结束,Java 虚拟机就会终止这个后台线程。

在使用后台线程时,有以下注意点;

  • Java 虚拟机所能保证的是:当所有前台线程运行结束 ,假如后台线程还在运行,Java 虚拟机会终止后台线程。此外,后台线程是否一定在前台线程的后面结束生命周期,还取决于程序的实现。
  • 只有在线程启动前(即调用 start() 方法以前),才能把它设置为后台线程。如果线程启动后,再调用这个线程的 setDaemon() 方法 ,会导致 IllegalThreadStateException 异常。
  • 由前台线程创建的线程默认悄况下仍然是前台线程,由后台线程创建的线程默认情况下仍然是后台线桯。

13.7 定时器

在 JDK 的 java.util 包中提供了一个实用类定时器 Timer, 它能够定时执行特定的任务。TimerTask 类表示定时器执行的一项任务。java.util.TimerTask 类是一个抽象类,它实现了 Runnable 接口。

java.util.Timer 类的构造方法有几种重载形式,有一种构造方法 Timer(boolean isDaemon) 允许把与 Timer 关联的线程设为后台线程。 Timer 类本身并不是线程类,但是在它的实现中,利用线程来执行定时任务。

Timer 类的 schedule(TimerTask task, long delay, long period) 方法用来设置定时器需要定时执行的任务。task 参数表示任务,delay 参数表示延迟执行的时间,以毫秒为单位,period 参数表示每次执行任务的间隔时间,以毫秒为单位。schedule 方法还有一种重载形式:schedule(TimerTask task, long delay) 表示仅仅执行一次任务 ,

package usetimer;
import java.util.Timer;
import java.util.TimerTask;
public class Machine extends Thread {
    private int a;
    public void start() {
        super.start();
        Timer timer = new Timer(true);              //把与 Timer 关联的线程设为后台线程
        TimerTask task = new TimerTask() {          //匿名类
            public void run() {
                reset();
            }
        };
        timer.schedule(task, 10, 50);               //设置定时任务
    }
    public void reset() {a = 0;}
    public void run() {
        for (int i = 0; i < 1000; i++) {
            System.out.println(getName() + a++);
            yield();
        }
    }
    public static void main(String[] args) throws Exception {
        Machine m = new Machine();
        m.start();
    }
}

13.8 线程的同步

线程的职责就是执行一 些操作,而多数操作都涉及处理数据。原子操作由相关的一组操作完成,这些操作可能会操作与其他线程共享的资源。为了保证得到正确的运算结果,一个线程在执行原了操作的期间,应该采取措施使得其他线程不能操作共享资源。

下面再以生产者-消费者的例子来演示多个线程对共享资源的竞争。

package producerconsumer;
public class SyncTest {
    public static void main(String[] args) {
        Stack stack = new Stack("stack1");
        Producer producer1 = new Producer(stack, "producer1");
        Consumer consumer1 = new Consumer(stack, "consumer1");
    }
}
//生产者线程,不断想堆栈内加入产品
class Producer extends Thread {
    private Stack theStack;
    public Producer(Stack s, String name) {
        super(name);
        theStack = s;
        start();                                // 启动自身生产者线程
    }
    public void run() {
        String goods;
        for (int i = 0; i < 200; i++) {
            //当一个生产者线程试图执行以下代码时,必须先获得 theStack 变量引用的 Stack 对象的锁
            synchronized(theStack) {        
                goods = "goods" + (theStack.getPointer() + 1);
                theStack.push(goods);
                System.out.println(getName() + "push" + goods + "to" + theStack.getName());
                yield();
            }
        }
    }
}
//消费者线程,不断从堆栈中取出产品
class Consumer extends Thread {
    public Consumer(Stack s, String name) {
        super(name);
        theStack = s;
        start();                                // 启动自身消费线程
    }
    public void run() {
        String goods;
        for (int i = 0; i < 200; i++) {
            goods = theStack.pop();
            System.out.println(getName() + "pop" + goods + "from" + theStack.getName());
            yield();
        }
    }
}
// 堆栈类
class Stack {
    private String name;
    private final int SIZE = 100;
    private String[] buffer = new String[SIZE];
    int pointer = -1;
    public Stack(String name) {this.name = name;}
    public String getName() {return name;}
    public synchronized int getPointer() {return pointer;}
    public synchronized String pop() {
        if (pointer == -1) return null;
        String goods = buffer[pointer];
        buffer[pointer] = null;
        Thread.yield();
        pointer--;
        return goods;
    }
    public synchronized boolean push(String goods) {
        if (pointer == SIZE - 1) return false;
        pointer++;
        Thread.yield();
        buffer[pointer] = goods;
    }
}

当生产者线程开始执行 push() 方法以后,或者消费者线程开始执行 pop() 方法时,都必须先获得 Stack 对象的锁,如果这把锁已经被其他线程占用,那么另一个线程就只能在锁池中等待。另外,对于生产者线程而言,getPoint() 和 push() 方法必须作为一个原子操作,当 producer1 线程执行这个原子操作时,其他线程不允许修改 Stack 对象的 pointer 属性。

为了保证每个线程能正常执行原子操作, Java 引入了同步机制,具体作法是在代表原子操作的程序代码前加上 synchronized 标记,这样的代码被称为同步代码块。

每个 Java 对象都有且只有一个同步锁,在任何时刻,最多只允许一个线程拥有这把锁。当消费者线程试图执行以上带有 synchronized(this) 标记的代码块时,消费者线程必须首先获得 this 关键字引用的 Stack 对象的锁。在以下两种情况,消费者线程有着不同的命运:

  • 假如这个锁已经被其他线程占用, Java 虚拟机就会把这个消费者线程放到 Stack 对象的锁池中,消费者线程进入 阻塞状态。 在 Stack 对象的锁池中可能会有许多等待锁的线程。等到其他线程释放了锁,Java 虚拟机会从锁池中随机地取出一个线程,使这个线程拥有锁,并且转到就绪状态。
  • 假如这个锁没有被其他线程占用,消费者线程就会获得这把锁,开始执行同步代码块。一般情况下,消费者线程只有执行完同步代码块,才会释放锁,使得其他线程能够获得锁 , 当然也有一些例外情况。

如果一个方法中的所有代码都属于同步代码,那么可以直接在方法前用 synchronized 修饰。下面两种方式是等价的:

public synchronized String pop() {...}
// 等价于
public String pop() { 
    synchronized(this) {...}
} 

线程同步的特征

线程同步具有以下特征 :

  1. 如果一个同步代码块和非同步代码块操作共享资源,这仍然会造成对共享资源的竞争。
  2. 每个对象都有唯一的同步锁。
    //如果修改SynTest类的main方法如下
    public static void main(String[] args) {
        Stack stack1 = new Stack("stack1");
        Stack stack2 = new Stack("stack2");
        Producer producer1 = new Producer(stack1, "producer1");
        Consumer consumer1 = new Consumer(stack2, "consumer1");
    }
    //生产者线桯与消费者线程分别操作不同的 Stack 对象,
    //当生产者线程试图执行stack1 对象的 push()方法时,只需获得 stack1 对象的锁
    //当消费者线程试图执行 stack2 对象的 pop()方法时,只需获得 stack2 对象的锁
    //因此这两个线程之间不会同步
    
  3. 在静态方法前面也可以使用 synchronized 修饰符。
    package synstatic;
    public class Machine implements Runnable { 
        private static int a = 1;                   //静态变量
        public synchronized static void go(int i) {
            a += i; 
            Thread.yield(); 
            a -= i; 
            System.out.println(a); 
        } 
        public void run() {     
            for (int i = 0; i < 1000; i++) 
                go(i); 
        }
        public static void main(String[] args) throws InterruptedException { 
            Machine machine = new Machine(); 
            Thread t1 = new Thread(machine); 
            Thread t2 = new Thread(machine); 
            t1.start(); 
            t2.start(); 
        }
    } 
    //每个被加载到 Java 虚拟机的方法区的类也有唯一的同步锁
    //当 t1 线程试图执行Machine 类的 go() 静态方法时,必须获得 Machine 类的锁,
    //假如这把锁已经被 t2 线程占用,那么 Java 虚拟机会把 t1 线程放到 Machine 类的锁池中,t1 线程进入阻塞状态。
    
  4. 当一个线程开始执行同步代码块,并不意味在着必须以不中断的方式运行。进入同步代码块的线程也可以执行 sleep() 或者 yield() 方法,此时它并没有释放锁,只是把运行机会(即 CPU) 让给了其他的线程。
    package synsleep; 
    public class Machine implements Runnable { 
        private int a= 1; //共享数据
        public void run() { 
            for (int i = 0; i < 1000; i++){ 
                synchronized(this){ 
                    a += i; 
                    try { 
                        Thread.sleep(500);              //给其他线程运行的机会
                    } catch (InterruptedExccption e){
                        throw new RuntimeException(e);
                    } 
                    a -= i; 
                System.out.println(Thread.currentThread().getName() + a); 
                }
            }
        } 
        public void go() { 
            for (int i = 0; i < 1000; i++) { 
                System.out.println(Thread.currentThread().getName() + i); 
                Thread.yield();
            }
        } 
        public static void main(String[] args) throws InterruptedException { 
            Machine machine = new Machine(); 
            Thread t1 = new Thread(machine); 
            Thread t2 = new Thread(machine); 
            t1.start(); 
            t2.start(); 
            machine.go()
        }  
    }
    //在 Machine 类的 run () 方法中包含操作实例变量 a 的原子操作,因此把它声明为同步代码块
    //Machine 类的 go() 方法仅仅操纵局部变量无须同步
    //当t1线程执行同步代码块中的sleep()方法时就开始睡眠,此时t1线程放弃CPU,但仍然持有 Machine 对象的锁 
    // 主线程有机会获得 CPU, 进入运行状态,而 t2 线程因为没有得到 Machine 对象的锁,依然在锁池中等待
    
  5. synchronized 声明不会被继承。如果一个用 synchronized 修饰的方法被了类覆盖,那么子类中这个方法不再保持同步,除非也用 synchronized 修饰。

同步与并发

同步是解决共享资源竞争的有效手段,但是,多线程的同步与并发是一对此消彼长的矛盾。为了提高并发性能,应该使得同步代码块中包含尽可能少的操作,使得一个线程能尽快释放锁,减少其他线程等待锁的时间 。

//有 10 个人到同一口井里打水,每个人都要打 10 桶水,人代表线程,井代表共享资源
//注释部分实现的是每人依次打10桶水的同步方式
//代码部分实现的是每人一次打1桶水轮10次的同步方式(对well尽早释放锁)
public class Person extends Thread {
    private Well well;
    public Person(Well well) {
        this.well = well;
        start();                        //启动自身线程
    }

    /*
    尽管调用了 yield 方法让出CPU,但是并没有释放Well对象的锁,导致其他Person线程不能进行
    public void run() {
        synchronized(well) {
            for (int i = 0; i < 10; i++) {
                well.withdraw();
                yield();
            }
        }
    }
    */
    public void run() {                 //取消同步
        for (int i = 0; i < 10; i++) {      
            well.withdraw();
            yield();
        }
    }
    public static void main(String[] args) {
        Well well = new Well();
        Person[] persons = new Person[10];
        for (int i = 0; i < 10; i++) 
            person[i] = new Person(well);
    }
}
class Well {
    private int water = 1000;               //共享数据
    public synchronized void withdraw() {   //对 withdraw方法同步
        water--;
        System.out.println(Thread.currentThread().getName() + "warter left:"+water);
    }
    /*
    public void withdraw() {
        water--;
        System.out.println(Thread.currentThread().getName() + "warter left:"+water);
    }
    */
}

线程安全的类

一个线程安全的类满足以下条件:

  • 这个类的对象可以同时被多个线程安全地访问。
  • 每个线程都能正常执行原子操作,得到正确的结果。
  • 每个线程的原子操作完成后,对象处于逻辑上合理的状态。

不可变类总是线程安全的,因为它的对象的状态始终不会变化,任何线程只能读取对象的状态,而不能改变它的状态。对于可变类,如果要保证它线程安全,必须根据实际情况,对某些原子操作进行同步。

可变类的线程安全往往以降低并发性能为代价,为了减小这一负面影响,可以采取以下措施:

  1. 只对可能导致资源竞争的代码进行同步。
  2. 如果一个可变类有两种运行环境:单线程运行环境和多线程运行环境,那么可以为这个类提供两种实现,在单线程运行环境中使用未采取同步的类的实现,在多线桯运行环境中使用采取同步的类的实现。

释放对象的锁

由于等待一个锁的线程只有获得这把锁之后,才能恢复运行,所以让持有锁的线程在不再需要锁的时候及时释放锁是很重要的。在以下情况,持有锁的线程会释放锁:

  • 执行完同步代码块,就会释放锁。
  • 在执行同步代码块的过程中,遇到异常而导致线程终止,锁也会被释放。
  • 在执行同步代码块的过程中,执行了锁所属对象的 wait() 方法,这个线程会释放锁,进入对象的等待池。

除了以上情况,只要持有锁的线程还没有执行完同步代码块,就不会释放锁。因此在以下情况,线程不会释放锁:

  • 在执行同步代码块的过程中,执行了 sleep() 方法,当前线程放弃 CPU,开始睡眠,在睡眠中不会释放锁。
  • 在执行同步代码块的过程中,执行了 yield() 方法,当前线程放弃 CPU,但不会释放锁。
  • 在执行同步代码块的过程中,其他线程执行了当前线程对象的 suspend() 方法,当前线程被暂停,但不会释放锁。

死锁

当一个线程等待由另一个线程持有的锁,而后者正在等待已被第一个线程持有的锁时,就会发生死锁。Java 虚拟机不监测也不试图避免这种情况,因此保证不发生死锁就成了程序员的责任。

package deadlock; 
public class Machine extends Thread { 
    private Counter counter;                        //共享数据
    public Machine(Counter counter){ 
        this.counter = counter; 
        start();                                    //启动自身线程
    } 
    public void run() { 
        for(int i = 0; i < 1000; i++) 
            counter.add(); 
    } 
    public static void main(String[] args) throws InterruptedException { 
        Counter counter1 = new Counter(); 
        Counter counter2 = new Counter(); 
        counter1.setFriend(counter2); 
        counter2.setFriend(counter1); 
        Machine machine1 = new Machine(counter1); 
        Machine machine2 = new Machine(counter2); 
    }
}
class Counter { 
    private int a; 
    private Counter friend; 
    public void setFriend(Counter friend) { 
        this.friend = friend; 
    } 
    public synchronized void add(){ 
        a++; 
        Thread.yield(); 
        friend.delete(); 
        System.out.println(Thread.currentThread().getName()+ ":add"); 
    }
    public synchronized void delete() {
        a--;
        System.out.println(Thread.currentThread().getNameQ+ ":delete"); 
    }  
}

在 Machine 类的 main() 方法中创建了 machine1 和 machine2 线程,这两个线程分别执行 counter1 和 counter2 对象的 add() 方法。而 counter1 对象的 add() 方法会调用 counter2 对象的 delete() 方法, counter2 对象的 add() 方法会调用 counterl 对象的 delete() 方法。machine1 线程和 machine2 线程分别持有 counter1 对象和 counter2 对象的锁,都在等待对方的锁,死锁就这样产生了,这两个线程都被阻塞,谁都无法恢复运行。因此,避免死锁的一个通用的经验法则是:当几个线程都要访问共享资源 A、B 和 C 时,保证每个线程都按照同样的顺序去访问它们。

13.9 线程通信

不同的线程执行不同的任务,如果这些任务有某种联系,线程之间必须能够通信,协调完成工作。java.lang.Object 类中提供了两个用于线程通信的方法:

  • wait():执行该方法的线程释放对象的锁,Java 虚拟机把该线程放到该对象的 等待池 中,该线程等待其他线程将它唤醒。
  • notify():执行该方法的线程唤醒在对象的等待池中等待的一个线程。Java 虚拟机从对象的等待池中随机地选择一个线程,把它转到对象的 锁池 中。

Object 类还有一个 notifyAll() 方法,该方法会把对象等待池中的所有线程都转到对象的锁池中。

//在之前生产消费模型进一步修改,使得生产者线程与消费者线程能够相互通信
package communication;
public class SyncTest {
    public static void main(String[] args) {
        Stack stack1 = new Stack("stack1");
        Producer producer1 = new Producer(stack1, "producer1");
        Consumer consumer1 = new Consumer(stack1, "consumer1");
        Consumer consumer2 = new Consumer(stack1, "consumer2");

        Stack stack2 = new Stack("stack2");
        Producer producer2 = new Producer(stack2, "producer2");
        Producer producer3 = new Producer(stack2, "producer3");
        Consumer consumer3 = new Consumer(stack1, "consumer3");
    }
}

class Producer extends Thread {...}
class Consumer extends Thread {...}
class Stack {
    ...
    public synchronized int getPointer() {return pointer;}
    public synchronized String pop() {
        //唤醒 this 引用的 Stack 对象的等待池中的线程
        this.notifyAll();                       
        while (pointer == -1) {
            System.out.println(Thread.currentThread().getName() + ":Wait");
            try {
                this.wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        String goods = buffer[pointer];
        buffer[pointer] = null;
        Thread.yield();
        pointer--;
        return goods;
    }
    public synchronized boolean push(String goods) {
        this.notifyAll();
        while (pointer == buffer.length - 1) {
            System.out.println(Thread.currentThread().getName() + ":Wait");
            try {
                this.wait();                        //this 引用Stack对象
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        pointer++;
        Thread.yield();
        buffer[pointer] = goods;
    }
}

值得注意的是,wait() 方法必须放在一个循环中,因为在多线程环境中,共享对象的状态随时可能被改变。当一个在对象等待池中的线程被唤醒后,并不一定立即恢复运行,等到这个线程获得了锁及 CPU 以 后才能继续运行,有可能此时对象的状态已经发生了变换。如果 wait() 方法放在一个 if 语句中,那么当 consumer2 线程恢复运行时,不会再判断 pointer 的值是否为 -1,因此会导致访问空的堆栈的错误。此外,对一 个对象的 wait() 和 notify() 的调用应该放在同步代码块中,并且同步代码块采用这个对象的锁。如果违反了这一规则,尽管在编译时不会检查这种错误,但在运行时会抛出 IllegalMonitorStateException 异常。

13.10 中断阻塞

当线程 A 处于阻塞状态时,如果线程 B 调用线程 A 的 interrupt() 方法 ,那么线程 A 会接收到一个 InterruptedException,线程 A 退出阻塞状态,开始进行异常处理。

//Machine 线程不断把变量 a 的值加 1 如果变量 a 的值大于 3 ,就会进入 Machine 对象的等待池
//定时器从 Machine 线程转到阻塞状态开始计时,如果 Machine 线程在等待池中的时间超过 3 秒,
//则定时器就会调用 Machine 线程的 interrupt() 方法,使 Machine 线程中断阻塞,并开始执行异常处理代码块
package interrupt;
import java.util.*;
public class Machine extends Thread{
    private int a = 0;
    private Timer timer = new Timer(true);
    public synchronized void reset() {a = 0;}
    public void run() {
        final Thread thread = Thread.currentThread();
        TimerTask timerTask = new TimerTask() {         //继承 TimerTasker 的匿名类
            public void run() {
                System.out.println(thread.getName() + "has waited for 3s");
                thread.interrupt();                     //中断Machine线程的阻塞
            }
        };
        while (true) {
            synchronized(this) {
                while (a > 3) {
                    timer.schedule(timerTask, 3000);    //三秒后执行定时任务
                    try {
                        this.wait();                    //如果等待时间超过3秒,则会收到InterruptedException
                    } catch (InterruptedException e) {
                        System.out.println(thread.getName() + "is interrupted");
                        /* DO SOMETHING */
                        return;
                    }
                }
                a++;
                System.out.println(a);
            }
        }
    }
    public static void main(String[] args) throws Exception {
        Machine machine = new Machine();
        machine.start();
    }
}

13.11 线程控制

当线程执行完 run() 方法后,它将自然终止运行。在前面的例子中,许多线程的 run() 方法都是由一个 for 循环组成的,线程执行的循环次数是固定的,这主要是为了简化范例。但在实际应用中,线程执行的循环次数往往是不确定的,到底何时结束线程,由外部程序决定。Thread 类中也提供了一些控制线程的方法:

  • start():启动线程。
  • suspend():使线程暂停。
  • resume():使暂停的线程恢复运行。
  • stop():终止线程。
    不过,从 JDK1.2 开始, 除了 start() 方法,其他 3 个控制线程的方法都被废弃(Deprecated)。

被废弃的 suspend() 和 resume() 方法

这两个方法被废弃 ,因为它们会导致以下危险:

  • 容易导致死锁。
  • 一个线程强制中断另一个线程的运行,会造成另一个线程操作的数据停留在逻辑上不合理的状态。

假设线程 A 获得了某个对象的锁,正在执行一个同步代码块 ,如果线程 B 调用线程 A 的 suspend() 方法,使线程 A 暂停,那么线程 A 会放弃 CPU,但是不会放弃占用的锁,这是导致死锁的根源所在。

//machine 线程和主线程都处于阻塞状态, machine 线程等待主线程将其恢复运行,
//而主线程等待 machine 线程释放 machine 对象的锁,死锁就这样产生了 。
package suspend; 
public class Machine extends Thread {
    private int a;                      //共享数据
    public void run() { 
        for(int i = 0; i < 1000; i++) { 
            synchronized(this){ 
                a += i;
                yield();                //给其他线程运行的机会
                a -= i; 
            }
        }
    }
    public synchronized void reset() { a == 0;} 
    public static void main(String[] args) throws InterruptedException { 
        Machine machine = new Machine(); 
        machine.start();                //machine线程启动,获得machine对象的锁执行run()方法
        yield();                        //给 machine 线程运行的机会
        machine.suspend();              //主线程调用suspend 让 machine 线程暂停
        machine.reset();                //主线程调用 machine 对象的同步代码块
        machine.resume();               //主线程使 machine 线程恢复运行
    }
}

当主线程调用 machine.suspend() 时,此时 machine 线程正在执行一个原子操作,会操作共享数据 a,machine 线程被迫中断运行,使共享数据停留在不稳定的中间状态。为了安全起见,只有 machine 线程本身才可以决定自己何时暂停运行。

应该使用 wait() 和 notify() 机制来 代替 suspend() 和 resume()。前者由线程自身执行一个对象的 wait()方法,从而进入阻塞状态。线程可以确保处理的数据稳定后,再使自己进入阻塞状态。此外,当线程执行 wait() 方法时,这个线程会自动释放锁,给予其他线程获得锁的机会,从而避免了死锁。

被废弃的 stop() 方法

Thread 类的 stop() 方法可以强制终止一个线程。但是从 JDK1.2 开始,废弃了 stop() 方法。假设线程 A 获得了某个对象的锁,正在执行一个同步代码块,如果线程 B 调用线程 A 的 stop() 方法,线程 A 就会终止,线程 A 在终止之前释放它持有的锁,这避免了 suspend() 和 resume() 方法引起的死锁问题。

但是,当线程 B 调用线程 A 的 stop() 方法时,如果线程 A 正在执行一个原子操作,会操作共享数据,那么会使共享数据停留在不稳定的中间状态。为了安全起见,只有线程 A 本身才可以决定何时终止运行。

以编程的方式控制线程

在实际编程中,一般是在受控制的线程中定义一个标志变量,其他线程通过改变标志变量的值,来控制线程的暂停、恢复运行以及自然终止。

package control;
public class ControlledThread extends Thread {
    public static final int RUN = 0;
    public static final int SUSP = 1;
    public static final int STOP = 2;
    private int state = RUN;
    public synchronized void setState(int state) {
        this.state = state;
        if (state == RUN) notify();
    }
    public synchronized boolean checkState() {
        while (state == SUSP) {
            try {
                System.out.println(Thread.currentThread().getName() + ":Wait");
                wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e.getMessage());
            }
        }
        if (state == STOP) {
            return false;
        }
        return true;
    }
}
//Machine 类继承 ControlledThread 类,因此 Machine 类是受控制的线程
//在 Machine 类的 run() 方法的循环中,每次循环结束前都会调用 checkState()方法判断接下来的操作状态
package control;
public class Machine extends ControlledThread {
    private int count;                  //共享数据
    public synchronized int getCount() { return count; }
    public synchronized void reset() {
        count = 0;
        System.out.println(Thread.currentThread().getName() + ":Reset");
    }
    public void run() {
        while (true) {
            synchronized (this) {
                count++;
                System.out.println(Thread.currentThread().getName() + ":Run" + count + "times");
            }
            if (!checkState()) {
                System.out.println(Thread.currentThread().getName() + ":Stop");
                break;
            }
        }
    }
    public static void main(String[] args) {
        Machine machine = new Machine();
        machine.start();
        for (int i = 0; i < 200; i++) {
            if (machine.getCount() > 5) {
                //暂停 machine 线程
                machine.setState(ControlledThread.SUSP);
                yield();
                machine.reset();
                //恢复 machine 线程
                machine.setState(ControlledThread.RUN);
            }
            yield()
        }
        //终止 machine 线程
        machine.setState(ControlledThread.STOP);
    }
}

值得注意的是,以上线程控制方式不是实时的,当主线程在某个时刻执行了 machine.setState(SUSP) 方法时,machine 线程并不会立即进入暂停状态。machine 线程必须先获得 CPU,开始执行 checkState() 方法,才会进入暂停状态。

13.12 线程组

Java 中的 ThreadGroup 类表示线程组,它能够对一组线程进行集中管理。用户创建的每个线程均属于某线程组。在创建一个线程对象时,可以通过以下构造方法指定它所属的线程组:

ThreadGroup(ThreadGroup group, String name)
ThreadGroup(ThreadGroup parent, String name)

假设线程 A 创建了线程 B, 如果创建线程 B 时没有在构造方法中指定线程组,那么线程 B 会加入到线程 A 所属的线程组中 。一旦线程加入某线程组,该线程就一直存在于该线程组中,直至线程死亡,不能在中途改变线程所属的线程组。当 Java 应用程序运行时,Java 虚拟机会创建名为 main 的线程组,默认情况下,所有线程都属于这个线程组。

用户创建的线程组都有一个父亲线程组,默认情况下,如果线程 A 创建了一个新的线程组,那么这个线程组以线程 A 所属的线程组作为父亲线程组。 此外,在构造线程组实例时 ,也可以显式地指定父亲线程组。

package group;
public class Machine extends Thread{
    public Machine(ThreadGroup group, String name) {
        super(group, name);
    }
    public void run() {
        for (int a = 0; a < 1000; a++) {
            System.out.println(Thread.currentThread().getName() + a);
            yield();
        }
    }
    public static void main(String[] args) throws Exception {
        ThreadGroup group = new ThreadGroup("machines");
        for (int i = 1; i < 5; i++) {
            Machine machine = new Machine(group, "machine" + i);
            machines.start();
        }
        int activeCount = group.activeCount();                  //获得当前活着的线程数目
        Thread[] machines = new Thread[activeCount];
        
        /* 假如此时activeCount值为5,再启动一个 Machine 线程
           Machine machine = new Machine(group, "machine" + 6); //第六个线程
           machine.start();
           再执行enumerate()方法只会存放5个线程引用而忽略一个线程
        */        
        group.enumerate(machines);                              //把当前活着的线程的引用存复制在数组中
        for (int i = 0; i < activeCount; i++)
            System.out.println(machines[i].getName() + "is Alive");
    }
}

由此可见,JDK 提供的 ThreadGroup API 不 是很健壮,因此不推荐使用 ThreadGroup 类。Thread Group 类的唯一比较有用的功能是它的 uncaughtException() 方法,详见下节。

13.13 处理线程未捕获的异常

在 Thread 类中提供了一个公共的静态的 UncaughtExceptionHandler 内部接口,它负责处理线程未捕获的异常,这个接口的完整名字为 Thread.UncaughtExceptionHandler,它的唯一的方法是 uncaughtException(Thread t, Throwable e),参数 t 表示抛出异常的线程,参数 e 表示具体的异常。

从 JDK5 版本开始,加强了对线程的异常处理。如果线程没有捕获异常,那么 Java 虚拟机会寻找相关的 UncaughtExceptionHandler 实例,如果找到,就调用它的 uncaughtException(Thread t, Throwable e) 方法。

Thread 类中提供了两个设置异常处理类的方法:
setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)
setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)
第一个方法是静态方法,设置线桯类的默认异常处理器,第二个方法是实例方法,设置线程实例的当前异常处理器。

每个线程实例都属于一个 ThreadGroup 线程组。 ThreahGroup 类实现了 Thread.UncaughtExceptionHandler 接口。当一个线程实例抛出未捕获的异常时,Java 虚拟机首先寻找线程实例的当前异常处理器,如果找到就调用它的 uncaughtException() 方法 。否则,把线程实例所属的线程组作为异常处理器,调用它的 uncaughtException() 方法。

ThreadGroup 类的 uncaughtException() 方法的处理流程如下:

  1. 如果这个线程组有一个父亲线程组,那么就调用它的 uncaughtException() 方法。
  2. 否则,如果线程实例所属的线程类具有默认异常处理器,那么就调用这个默认异常处理器的 uncaughtException() 方法。
  3. 否则,就把来自方法调用堆栈的异常信息打印到标准输出流 System.err 中。
package exhandler;
class MachineGroup extends ThreadGroup {
    public MachineGroup() {
        super("MachineGroup");
    }
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println(getName() + "Catch an Exception from" + t.getName());
        super.uncaughtException(t, e);
    }
}
class MachineHandler implements Thread.UncaughtExceptionHandler {
    private String name;
    public MachineHandler(String name) {
        this.name = name;
    }
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println(name + "Catch an Exception from" + t.getName());
    }
}
public class Machine extends Thread {
    public Machine(ThreadGroup group, String name) {
        super(group, name);
    }
    public void run() {
        int a = 1 / 0;              //ArithmeticException
    }
    public static void main(String[] args) throws Exception {
        ThreadGroup group = new MachineGroup();

        //设置 Machine 类的默认异常处理器
        UncaughtExceptionHandler defaultHandler = new MachineHandler("DefaultHandler");
        Machine.setDefaultUncaughtExceptionHandler(deaultHander);

        Machine m1 = new Machine(group, "m1");
        Machine m2 = new Machine(group, "m2");

        //设置 m2 的当前异常处理器
        UncaughtExceptionHandler currHandler = new MachineHandler("m2.hander");
        m2.setDefaultUncaughtExceptionHandler(currHandler);

        m1.start();
        m2.start();
    }
}
/* 打印结果
MachineGroup Catches an Exception from m1 
DefaultHandler Catches an Exception from m1 
m2.handler Catches an Exception from m2 
 */

当 ml 和 m2 线程运行时,由于执行了整数除以零的操作,因此会分别抛出 ArithmeticException 异常。对于m2 抛出的异常,直接由当前异常处理器 currHandler 处理,对于 m1 抛出的异常,由于没有设置当前异常处理器,因此由 MachineGroup 处理,MachineGroup 向控制台打印些信息后接着再转手由 Machine 类的默认异常处理器 defaultHander 处理。

13.14 ThreadLocal 类

java.lang.ThreadLocal 可用来存放线程的局部变量,每个线程都有单独的局部变量,彼此之间不会共享。ThreadLocal <T> 类主要包括以下 3 个方法:

  • public T get():返同当前线程的局部变量。
  • protected T initialValue():返同当前线程的局部变量的初始值。
  • public void set(T value):设置当前线程的局部变量。

initialValue() 方法为 protected 类型,它是为了被子类覆盖而特意提供的,该方法返回当前线程的局部变量的初始值,这个方法是一个延迟调用方法,当线程第一次调用 ThreadLocal 对象的 get() 或者 set() 方法时才执行, 并且仅执行一次。在 ThreadLocal 类本身的实现中,initialValue() 方法直接返同一个 null:

protected T initialValue() { return null; } 

ThreadLocal 是如何做到为每一个线程提供一个单独的局部变量的呢?其实很简单,在 ThreadLocal 类中有个 Map 缓存,用于存储每一个线程的局部变量。

// ThreadLocal实现的总体思路
package mypack;
import java.util.*;
public class ThreadLocal<T> {
    private Map<Runnable, T> values = 
        Collection.synchronizedMap(new HashMap<Runnable, T>());

    public T get() {
        Thread curThread = Thread.currentThread();
        T o = values.get(curThread);
        if (o == null && !values.containsKey(curThread)) {
            o = initialValue();
            values.put(curThread, o);
        }
        return o;
    }
    public void set(T newValue) {
        values.put(Thread.currentThread(), new Value);
    }
    protected T initialValue() {
        return null;
    }
}

下面的例程演示了 Java.lang.ThreadLocal 类的用法。

//Counter 类用来为每个线程分配一个序列号 serialCount,引用自 ThreadLocal 的匿名子类的实例
//LocalTester 是个线程类,在它的 run() 方法中打印门己的序列号,然后把序列号加 2
class Counter {
    private static int count;

    //每个线程的序列号声明为 ThreadLocal 类型
    //引用一个 ThreadLocal 的匿名子类的实例
    private static ThreadLocal<Integer> serialCount = new ThreadLoacl<Intger>() {
        protected synchronized Integer initialValue() {
            return new Integer(count++);
        }
    };
    public static int get() {
        return serialCount.get();
    }
    public static void set(int i) {
        serialCount.set(i);
    }
    public class LocalTester extends Thread{
        public void run() {
            for (int i = 0; i < 3; i++) {
                int c = Counter.get();
                System.out.println(getName() + c);
                Counter.set(c += 2);
            }
        } 
    }
    public static void main(String[] args) {
        Thread t1 = new LocalTester();
        Thread t2 = new LocalTester();
        t1.start();
        t2.start();
    }
}
//打印结果 Thread-0:0 Thread-1:1 Thread-0:2 Thread-0:4 Thread-1:3 Thread-1:5

13.15 concurrent 并发包

编写多线程的程序代码时,既要保证线程的同步,又要避免死锁,还要考虑并发性能,因此对开发人员的要求很高。为了帮助开发人员编写出高效安全的多线程代码,从 JDK5 开始,增加了 Java.util.cuncurrent 并发包,它提供了许多实用的处理多线程的接口和类。

用于线程同步的 Lock 外部锁

每个 Java 对象都有一个用于同步的锁,但这实际上是概念上的锁。而在 Java.util.cuncurrent.locks 包中,Lock 接口及它的实现类 专门 表示用于同步的锁。为了叙述方便 ,本章把 Lock 锁称为外部锁,前文所讲的 Java 对象的锁称为 Java 对象内部锁。

Lock 锁的优势在于它提供了更灵活地获得同步代码块的锁的方式:

  • lock():当前线程获得同步代码块的锁,如果锁被其他线程占用,那就进入阻塞状态。这种处理机制和 Java 对象内部锁是一样的。
  • tryLock():当前线程试图获得同步代码块的锁,如果锁被其他线程占用,那就立即返回 false,否则返回true。
  • tryLock(long time, TimeUnit unit):该方法和上面的不带参数的 tryLock() 方法的作用相似。区别在于本方法设定了时间限制。如果锁被其他线程占用,当前线程会先进入阻塞状态。如果在时间限定范围内获得了锁,那就返同 true;如果超过时间限定范围还没有获得锁,那就返同 false。例如 lock.tryLock(50L, TimeUnit.SECONDS) 表示设定的时间限制为 50 秒。

和以上 lock() 方法及 tryLock() 方法对应,Lock 接口的 unlock() 方法用于释放线程所占用的同步代码块的锁。

Lock 接口有一个实现类 ReentrantLock,它有以下构造方法:

  • Reentrantlock(): 默认构造方法,创建一个常规的锁。
  • ReentrantLock(boolean fair):如果 fair 参数为 true,会创建一个带有公平策略的锁。否则就创建一个常规的锁。所谓 公平策略,是指会保证让阻塞较长时间的线程有机会获得锁。使用公平锁时,有两个注意事项:一是公平锁的公平机制是以降低运行性能为代价的;二是公平锁依赖于底层线程调度的实现,不能完全保证公平。

在利用外部锁来对线程同步时,一般采用以下编程模式:

public class Sample { 
    private final Reentrant Lock lock = new ReentrantLock();        //创建锁
    ...
    public void method() { 
        lock.lock();                                                //获得锁
        try { 
            //...需要同步的原子操作
        } finally { 
            lock.unlock();                                          //释放锁
        }
    }
}

使用外部锁时,需要同步的操作放在 try 代码块中。为保证出现异常的情况下,也会执行释放锁的操作,特意把解锁语句放在 finally 代码块中。

package lock;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class Machine implements Runnable {
    private final Lock machineLoack = new ReentrantLoack();         //创建外部锁
    int data = 100;                                                 //共享数据
    public void run() {
        machineLock.lock();                                         //用外部锁来同步
        try {
            data++;
            Thread.currentThread().sleep(1000);
            data--;
            System.out.println(Thread.currentThread().getName() + data);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            machineLock.unlock();                                   //释放锁
        }
    }
    public static void main(String[] args) throws Exception {
        Machine machine = new Machine();
        for (int i = 0; i < 10; i++){                        //创建10个线程
            Thread thread = new Thread(machine);             //都执行同一个 Machine对象的 run() 方法
            thread.start();
        }
    }
}

用于线程连信的 Condition 条件接口

java.lang.concurrent.locks.Condition 条件接口用于线程之间的通信。Lock 接口的 newCondition() 方法返回实现了 Condition 接口的实例。

Condition 接口中有以下方法:

  • await(): 作用和 Object 类的 wait() 方法相似。当前线程释放外部锁,进入等待池中,等待其他线程将它唤醒。
  • await(long time, TimeUnit unit):当前线程释放外部锁,进入等待池中,等待其他线程将它唤醒。如果在参数设定的时间范围内没有被唤醒,就不再等待,直接返回 false,否则返同 true。
  • signal():作用和 Object 类的 notify() 方法相似。当前线程唤醒等待池中的个线程。
  • signalAll():作用和 Object 类的 notifyAll() 方法相似。当前线程唤醒等待池中的所有线程。
//重写 SyncTest 类,利用Condition 接口来进行线程之间通信
package lockcommunication;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class SyncTest {
    public static void main(String[] args) {
        Stack stack1 = new Stack("stack1");
        Producer producer1 = new Producer(stack1, "producer1");
        Consumer consumer1 = new Consumer(stack1, "consumer1");
        Consumer consumer2 = new Consumer(stack1, "consumer2");
    }
}

class Producer extends Thread {
    private Stack theStack;
    public Producer(Stack s, String name) {
        super(name);
        theStack = s;
        start();                                // 启动自身生产者线程
    }
    public void run() {
        String goods;
        Lock stackLock = theStack.getLock();
        for (int i = 0; i < 200; i++) {
            stackLock.lock();
            try { 
                goods = "goods" + (theStack.getPointer() + 1);
                theStack.push(goods);
            } finally {    
                stackLock.unlock();
            }
            System.out.println(getName() + "push" + goods + "to" + theStack.getName());
            sleep(100);
        }
    }
    public void sleep(int m) {
        try {
            super.sleep(m);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Consumer extends Thread {
    public Consumer(Stack s, String name) {
        super(name);
        theStack = s;
        start();                                // 启动自身消费线程
    }
    public void run() {
        String goods;
        for (int i = 0; i < 200; i++) {
            goods = theStack.pop();
            System.out.println(getName() + "pop" + goods + "from" + theStack.getName());
            yield();
        }
    }
}

class Stack {
    private String name;
    private final int SIZE = 100;
    private String[] buffer = new String[SIZE];
    int pointer = -1;

    private final Lock stackLock;
    private Condition condition;

    public Stack(String name) {
        this.name = name;
        stackLock = new ReenterantLock();
        condition = stackLock.newCondition();
    }
    public String getName() {return name;}
    public Lock getLock() {return stackLock;}
    public synchronized int getPointer() {
        stackLock.lock();
        try {
            return pointer;            
        } finally {
            stackLock.unlock();
        }
    }
    public synchronized String pop() {
        stackLock.lock();
        try {
            condition.signalAll();
            while(pointer == -1) {
                System.out.println(Thread.currentThread().getName() + ":Wait");
                condition.await();
            }
                    String goods = buffer[pointer];
            buffer[pointer] = null;
            Thread.yield();
            pointer--;
            return goods;
        } catch (InterruptedException e) {
            throw new RuntimeException(e)''
        } finally {
            stackLock.unlock();
        }
    }
    public synchronized void push(String goods) {
        stackLock.lock();
        try {
            condition.signalAll();
            while (pointer == buffer.length - 1) {
                System.out.println(Thread.currentThread().getName() + ":Wait");
                condition.await();
            }
            pointer++;
            Thread.yield();
            buffer[pointer] = goods;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            stackLock.unlock();
        }
    }
}

以上程序在 Stack 类的 push() 和 pop() 方法中通过 Condition 对象的 await() 方法和 signalAll() 方法来进行生产者线程和消费者线程之间的通信。另外 ,在生产者 Producer 类的 run() 方法中, 也利用 Stack 对象的外部锁来对操作 Stack 对象的代码进行同步。

支持异步计算的 Callable 接口和 Future 接口

Runnable 接口的 run() 方法的返回类型为 void。假如线桯 A 执行一个运算任务,线程 B 需要获取线程 A 的运算结果,这该如何实现呢?如果直接靠编程来实现,就需要定义一个存放运算结果的共享变量,线程 A 和线程 B 都可以访问这个共享变量,并且需要对操作共享变量的代码块进行同步。

在 JDK5 中,java.util.concurrent 包中的一些接口和类提供了更简单的支持异步运算的方法。涉及 java.util.concurrent 包中的以下类和接口:

  • Callable 接口:它和 Runnable 接口有点类似,都指定了线程所要执行的操作。区别在于,Callable 接口是在 call() 方法中指定线程所要执行的操作的, 并且该方法有泛型的返回值 <V>。 此外,Callable 实例不能像 Runnable 实例那样,直接作为 Thread 类的构造方法的参数。
  • Future 接口:能够保存异步运算的结果。它有以下方法:
    • get():返回异步运算的结果。如果运算结果还没有出来,当前线程就会被阻塞,直到获得运算结果,才结束阻塞。
    • get(long timeout, TimeUnit unit):和第一个不带参数的 get() 方法的作用相似,区别在于本方法为阻塞设了限定时间。如果超过参数设置的限定时间,还没有获得运算结果,就会抛出 TimeoutException。
    • cancel(boolean mayInterrupt):取消该运算。如果运算还没开始,那就立即取消。如果运算已经开始,并且 mayInterrupt 参数为 true, 那么会取消该运算。否则,如果运算已经开始,并且 mayInterrupt 参数为 false,那么不会取消该运符,而是让其继续执行下去。
    • isCancelled():判断运算是否已经被取消,如果取消,就返回 true。
    • isDone():判断运算是否已经完成,如果已经完成,就返回 true。
  • FutureTask 类 :它是一个适配器,同时 实现Runnable 接口和 Future 接口,又会 关联 一个 Callable 实例。它实际上把 Callable 接口转换成了 Runnable 接口。FutureTask 实例可以作为 Thread 类的构造方法的参数。例如 ,以下代码把一个 Callable 实例传给 FutureTask 类 的构造方法,接着把 FutureTask 实例传给 Thread 类的构造方法:
    Callable myComputation = ...
    FutureTask<Integer> task = new FutureTask<Integer>(myComputation);
    Thread thread = new Thread(task);
    thread.start();
    // 当以上线程启动后,会执行 Callable 实例中的 call() 方法,
    // 并且运符结果保存在 FutureTask 实例中 。
    
// Machine 类实现了 Callable 接口 machine 线程负责执行 call() 方法,并且返回运算的结果
package future;
import java.util.concurrent.*;
public class Machine implements Callable<Integer> {
    public Integer call() {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
            try {
                Thread.currentThread().sleep(20);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return sum;
    }
    public static void main(String[] args) throws Exception {
        FutureTask<Integer> task = new FutureTask<Integer>(new Machine());
        Thread machine = new Thread(task);
        machine.start();                        // machine线程执行Machine类的call()方法
        System.out.println("The Sum Is" + task.get());              //获取运算结果
    }
}

线程调用 task.get() 方法, 当 machine 线程还没运算完毕时,主线程就会阻塞,machine 线程执行完 call() 方法,中线程才会获得运算结果,并从 task.get() 方法中退出。

通过线程池来高效管理多个线程

每个线程执行完一个 Runnable 实例的 run() 方法,就会结束生命周期。在多线程环境中,不断地创建和销毁线程既费时又消耗系统资源。为了提高程序的性能,java.util.concurrent 并发包提供了用线程池来管理多线程的机制。它的基本原理是仅仅创建数量有限的线程,每个线程都会待续不断地执行各种任务。

Executor 接口 表示线程池,它的 execute(Runnable command) 方法用来提交 Runnable 类型参数 command 的 run() 方法所指定的任务,线程池会调度空闲的线程来执行该任务。至于到底何时执行该任务,这是由线程池在运行时动态决定的。它的 ExecutorService 子接口 具有管理线程池的一些方法:

  • shutdown():预备关闭线程池。如果已经有任务开始执行,那么要等这些任务执行完毕后,才会真正地关闭线程池。那些还没有开始执行的任务则会被忽略不再执行。
  • shutdownNow():终止已经开始执行的任务,立即关闭线程池。
  • awaitTennination():等待线程池关闭。
  • isTenninated():判断线程池是否关闭,如果关闭,就返回 true,否则返回 false。

ExecutorService 接口的 submit(Callable<T> task)submit(Runnable task) 方法的作用与 Executor 接口的 execute(Runnable command) 方法相似,都用于向线程池提交任务。区别在于前两个 submit() 方法支持异步运算,它们都会返回表示异步运算结果的 Future 对象。

Excutors 包含一些静态方法,负责生成各种类型的 ExecutorService 线程池实例:

  • newCachedThreadPool():创建拥有缓存的线程池。有任务时才创建新线程,空闲的线程在缓存中被保留 60 秒。
  • newFixedThreadPool(int nThreads):创建拥有固定数目线程的线程池。空闲线程会一直保留。参数 nThreads 用于设定线程池中线程的数目。
  • newSingleThreadExecutor():创建只有一个线桯的线程池。这一单个线程会依次执行每个任务。如果这个线程因为异常而终止,不会重新创建替代它的线程,这是与 newFixedThreadPool(1) 方法的不同之处。 newFixedThreadPool(1) 方法尽管也只创建一个线程,但是如果这个线程意外终止,线程池会重新创建一个新的线程来替代它继续执行任务。
  • newScheduledThreadPool(int corePoolSize):线程池会按时间计划来执行任务,允许用户设定计划执行任务的时间。参数 corePoolSize 设定线程池中线程的最小数目。任务较多时,线程池可能会创建更多的线桯来执行任务。
  • newSingleThreadScheduledExecutor():创建只有一个线程的线程池。这一单个线程能按照时间计划来执行任务。
// Machine 类利用线程池来执行多个 Machine 对象的 run() 方法指定的任务
package pool;
import java.util.concurrent.*;
public class Machine implements Runnable {
    private int id;
    public Machine(int id) {this.id = id;}
    public void run() {
        for (int a = 0; a < 10; a++) {
            System.out.println(Thread.currentThread().getName() + id + a);
            Thread.currentThread().yield();
        }
    }
    public static void main(String[] args) throws Exception {
        ExecutorService service = Executors.newFixedThreadPool(2);      //线程池有2个线程
        for (int i = 0; i < 5; i++) {
            service.execute(new Machine(i));             //向线程池递交五个Machine任务
            //或者 service.submit(new Machine(i));
        }
        service.shutdown();
    }
}

线程池会调度池中的两个线程来执行这些任务。当程序执行 service.shutdown() 方法时,该方法不会立即关闭线程池,只有当已经开始执行的任务全部执行完毕后,才会关闭线程池。

BlockingQueue 阻塞队列

java.util.concurrent.BlockingQueue 接口继承了 Java.util.Queue 接口,BlockingQueue 接口为多个线程同时操作同一个队列提供了 4 种处理方案,方案一和方案二来自 Queue 接口,方案三和方案四是在 BlockingQueue 接口中新增的。

操作处理方案一:抛出异常处理方案二:返回特定值处理方案三:线程阻塞处理方案四:超时
添加元素add(e)offer(e)put(e)offer(e,time,unit)
删除元素remove()poll()take()poll(time,unit)
读取元素element()peek()--
  • 线程阻塞。在通过 put(e) 方法向队列尾部添加元素时,如果队列已满,则当前线程进入阻塞状态,直到队列有剩余的容量来添加元素,才退出阻塞。在通过 take() 方法从队列头部删除并返回元素时,如果队列为空,则当前线程进入阻塞状态,直到从队列中成功删除并返回元素为止。
  • 超时。超时和以上线程阻塞有一些共同之处。两者的区别在于当线程在特定条件下进入阻塞状态后,如果超过了 offer(e, time, unit)poll(time , unit) 方法的参数所设置的时间限制,那么也会退出阻塞状态,分别返回 false 和 null。

如 java.util.concurrent 包中,BlockingQueue 接口主要有以下实现类:

  • LinkedBlockingQueue 类:默认情况下,LinkedBlockingQueue 的容量是没有上限的(确切地说,默认的容量为 Integer.MAX_VALUE),但是也可以选择指定其最大容量。它是基于链表的队列,此队列按 FIFO(先进先出)的原则存取元素。
  • ArrayBlockingQueue 类:它的 ArrayBlockingQueue(int capacity, boolean fair) 构造方法允许指定队列的容量,并可以选择是否采用公平策略。如果参数 fair 被设置为 true , 那么等待时间最长的线程会优先访问队列(其底层实现是通过将 ReentrantLock 设置为使用公平策略来达到这种公平性的)。通常,公平
    性以降低运行性能为代价,所以只有在确实非常需要的时候才使用这种公平机制。 ArrayBlockingQueue 是基于数组的队列,此队列按 FIFO(先进先出)的原则存取元素。
  • PriorityBlockingQueue 是一个带优先级的队列,而不是先进先出队列。元素按优先级顺序来删除,该队列的容量没有上限。
  • DelayQueue: 在这个队列中存放的是延期元素,也就是说这些元索必须实现 java.util.concurrent.Delayed 接口。只有延迟期满的元素才能被取出或删除。当一个元素的 getDelay(TimeUnit unit) 方法返回一个小于或等于零的值时,则表示延迟期满。DelayQueue 队列的容量没有上限。
//重写 SyncTest 类,生产消费线程同时操作同一个阻塞队列
package blockque;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class SyncTest {
    public static void main(String[] args) {
        BlockingQueue<String> queue = new ArrayBlockingQueue<String>(100);
        Producer producer1 = new Producer(queue, "producer1");
        Consumer consumer1 = new Consumer(queue, "consumer1");
        Consumer consumer2 = new Consumer(queue, "consumer2");
    }
}

class Producer extends Thread {
    private BlockingQueue<String> queue;

    public Producer(BlockingQueue<String> queue, String name) {
        super(name);
        this.queue = queue;
        start();                                // 启动自身生产者线程
    }
    public void run() {
        String goods;
        for (int i = 0; i < 200; i++) {
            try { 
                goods = "goods" + i;
                queue.put(goods);
                System.out.println(getName() + "put" + goods + "to queue");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

class Consumer extends Thread {
    private BlockingQueue<String> queue;

    public Consumer(BlockingQueue<String> queue, String name) {
        super(name);
        this.queue = queue;
        start();                                // 启动自身消费线程
    }
    public void run() {
        String goods;
        for (int i = 0; i < 200; i++) {
            try {
                goods = queue.take();
                yield();
                System.out.println(getName() + "take" + goods + "from queue");
            } catch (Exception e) {
                e.printlnStackTrace();
            }
        }
    }
}

在 Consumer 类的 run() 方法中,通过 queue.take() 方法从队列中取出元素,如果队列为空,消费者线程会进入阻塞状态,直到生产者线程加入了产品 ,消费者线程才会退出阻塞。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值