初学Java多线程:线程简介
本文为Java多线程初学者系列的第一篇,简单介绍了什么是Java的线程及多线程的好处。由于Java是纯面向对象语言,因此,Java的线程模型也是面向对象的。
一、线程概述
线程是程序运行的基本执行单元。当操作系统(不包括单线程的操作系统,如微软早期的DOS)在执行一个程序时,会在系统中建立一个进程,而在这个进程中,必须至少建立一个线程(这个线程被称为主线程)来作为这个程序运行的入口点。因此,在操作系统中运行的任何程序都至少有一个主线程。
进程和线程是现代操作系统中两个必不可少的运行模型。在操作系统中可以有多个进程,这些进程包括系统进程(由操作系统内部建立的进程)和用户进程(由用户程序建立的进程);一个进程中可以有一个或多个线程。进程和进程之间不共享内存,也就是说系统中的进程是在各自独立的内存空间中运行的。而一个进程中的线可以共享系统分派给这个进程的内存空间。
线程不仅可以共享进程的内存,而且还拥有一个属于自己的内存空间,这段内存空间也叫做线程栈, 是在建立线程时由系统分配的,主要用来保存线程内部所使用的数据,如线程执行函数中所定义的变量。
注意:任何一个线程在建立时都会执行一个函数,这个函数叫做线程执行函数。也可以将这个函数看做线程的入口点(类似于程序中的main函数)。无论使用什么语言或技术来建立线程,都必须执行这个函数(这个函数的表现形式可能不一样,但都会有一个这样的函数)。如在Windows中用于建立线程的API函数CreateThread的第三个参数就是这个执行函数的指针。
在操作系统将进程分成多个线程后,这些线程可以在操作系统的管理下并发执行,从而大大提高了程序的运行效率。虽然线程的执行从宏观上看是多个线程同时执行,但实际上这只是操作系统的障眼法。由于一块CPU同时只能执行一条指令,因此,在拥有一块CPU的计算机上不可能同时执行两个任务。而操作系统为了能提高程序的运行效率,在一个线程空闲时会撤下这个线程,并且会让其他的线程来执行,这种方式叫做线程调度。我们之所以从表面上看是多个线程同时执行,是因为不同线程之间切换的时间非常短,而且在一般情况下切换非常频繁。假设我们有线程A和B。在运行时,可能是A执行了1毫秒后,切换到B后,B又执行了1毫秒,然后又切换到了A,A又执行1毫秒。由于1毫秒的时间对于普通人来说是很难感知的,因此,从表面看上去就象A和B同时执行一样,但实际上A和B是交替执行的。
二、线程给我们带来的好处
如果能合理地使用线程,将会减少开发和维护成本,甚至可以改善复杂应用程序的性能。如在GUI应用程序中,还以通过线程的异步特性来更好地处理事件;在应用服务器程序中可以通过建立多个线程来处理客户端的请求。线程甚至还可以简化虚拟机的实现,如Java虚拟机(JVM)的垃圾回收器(garbage collector)通常运行在一个或多个线程中。因此,使用线程将会从以下五个方面来改善我们的应用程序:
1. 充分利用CPU资源
现在世界上大多数计算机只有一块CPU。因此,充分利用CPU资源显得尤为重要。当执行单线程程序时,由于在程序发生阻塞时CPU可能会处于空闲状态。这将造成大量的计算资源的浪费。而在程序中使用多线程可以在某一个线程处于休眠或阻塞时,而CPU又恰好处于空闲状态时来运行其他的线程。这样CPU就很难有空闲的时候。因此,CPU资源就得到了充分地利用。
2. 简化编程模型
如果程序只完成一项任务,那只要写一个单线程的程序,并且按着执行这个任务的步骤编写代码即可。但要完成多项任务,如果还使用单线程的话,那就得在在程序中判断每项任务是否应该执行以及什么时候执行。如显示一个时钟的时、分、秒三个指针。使用单线程就得在循环中逐一判断这三个指针的转动时间和角度。如果使用三个线程分另来处理这三个指针的显示,那么对于每个线程来说就是指行一个单独的任务。这样有助于开发人员对程序的理解和维护。
3. 简化异步事件的处理
当一个服务器应用程序在接收不同的客户端连接时最简单地处理方法就是为每一个客户端连接建立一个线程。然后监听线程仍然负责监听来自客户端的请求。如果这种应用程序采用单线程来处理,当监听线程接收到一个客户端请求后,开始读取客户端发来的数据,在读完数据后,read方法处于阻塞状态,也就是说,这个线程将无法再监听客户端请求了。而要想在单线程中处理多个客户端请求,就必须使用非阻塞的Socket连接和异步I/O。但使用异步I/O方式比使用同步I/O更难以控制,也更容易出错。因此,使用多线程和同步I/O可以更容易地处理类似于多请求的异步事件。
4. 使GUI更有效率
使用单线程来处理GUI事件时,必须使用循环来对随时可能发生的GUI事件进行扫描,在循环内部除了扫描GUI事件外,还得来执行其他的程序代码。如果这些代码太长,那么GUI事件就会被“冻结”,直到这些代码被执行完为止。
在现代的GUI框架(如SWING、AWT和SWT)中都使用了一个单独的事件分派线程(event dispatch thread,EDT)来对GUI事件进行扫描。当我们按下一个按钮时,按钮的单击事件函数会在这个事件分派线程中被调用。由于EDT的任务只是对GUI事件进行扫描,因此,这种方式对事件的反映是非常快的。
5. 节约成本
提高程序的执行效率一般有三种方法:
(1)增加计算机的CPU个数。
(2)为一个程序启动多个进程
(3)在程序中使用多进程。
第一种方法是最容易做到的,但同时也是最昂贵的。这种方法不需要修改程序,从理论上说,任何程序都可以使用这种方法来提高执行效率。第二种方法虽然不用购买新的硬件,但这种方式不容易共享数据,如果这个程序要完成的任务需要必须要共享数据的话,这种方式就不太方便,而且启动多个线程会消耗大量的系统资源。第三种方法恰好弥补了第一种方法的缺点,而又继承了它们的优点。也就是说,既不需要购买CPU,也不会因为启太多的线程而占用大量的系统资源(在默认情况下,一个线程所占的内存空间要远比一个进程所占的内存空间小得多),并且多线程可以模拟多块CPU的运行方式,因此,使用多线程是提高程序执行效率的最廉价的方式。
三、Java的线程模型
由于Java是纯面向对象语言,因此,Java的线程模型也是面向对象的。Java通过Thread类将线程所必须的功能都封装了起来。要想建立一个线程,必须要有一个线程执行函数,这个线程执行函数对应Thread类的run方法。Thread类还有一个start方法,这个方法负责建立线程,相当于调用Windows的建立线程函数CreateThread。当调用start方法后,如果线程建立成功,并自动调用Thread类的run方法。因此,任何继承Thread的Java类都可以通过Thread类的start方法来建立线程。如果想运行自己的线程执行函数,那就要覆盖Thread类的run方法。
在Java的线程模型中除了Thread类,还有一个标识某个Java类是否可作为线程类的接口Runnable,这个接口只有一个抽象方法run,也就是Java线程模型的线程执行函数。因此,一个线程类的唯一标准就是这个类是否实现了Runnable接口的run方法,也就是说,拥有线程执行函数的类就是线程类。
从上面可以看出,在Java中建立线程有两种方法,一种是继承Thread类,另一种是实现Runnable接口,并通过Thread和实现Runnable的类来建立线程,其实这两种方法从本质上说是一种方法,即都是通过Thread类来建立线程,并运行run方法的。但它们的大区别是通过继承Thread类来建立线程,虽然在实现起来更容易,但由于Java不支持多继承,因此,这个线程类如果继承了Thread,就不能再继承其他的类了,因此,Java线程模型提供了通过实现Runnable接口的方法来建立线程,这样线程类可以在必要的时候继承和业务有关的类,而不是Thread类。
初学Java多线程:用Thread类创建线程
在Java中创建线程有两种方法:使用Thread类和使用Runnable接口。任何一个Java程序都必须有一个主线程。学习Java多线程,需要先从用Thread类创建线程开始。
在Java中创建线程有两种方法:使用Thread类和使用Runnable接口。在使用Runnable接口时需要建立一个Thread实例。因此,无论是通过Thread类还是Runnable接口建立线程,都必须建立Thread类或它的子类的实例。Thread类的构造方法被重载了八次,构造方法如下:
- public Thread( );
- public Thread(Runnable target);
- public Thread(String name);
- public Thread(Runnable target, String name);
- public Thread(ThreadGroup group, Runnable target);
- public Thread(ThreadGroup group, String name);
- public Thread(ThreadGroup group, Runnable target, String name);
- public Thread(ThreadGroup group, Runnable target, String name, long stackSize);
Runnable target
实现了Runnable接口的类的实例。要注意的是Thread类也实现了Runnable接口,因此,从Thread类继承的类的实例也可以作为target传入这个构造方法。
String name
线程的名子。这个名子可以在建立Thread实例后通过Thread类的setName方法设置。如果不设置线程的名子,线程就使用默认的线程名:Thread-N,N是线程建立的顺序,是一个不重复的正整数。
ThreadGroup group
当前建立的线程所属的线程组。如果不指定线程组,所有的线程都被加到一个默认的线程组中。关于线程组的细节将在后面的章节详细讨论。
long stackSize
线程栈的大小,这个值一般是CPU页面的整数倍。如x86的页面大小是4KB。在x86平台下,默认的线程栈大小是12KB。
一个普通的Java类只要从Thread类继承,就可以成为一个线程类。并可通过Thread类的start方法来执行线程代码。虽然Thread类的子类可以直接实例化,但在子类中必须要覆盖Thread类的run方法才能真正运行线程的代码。下面的代码给出了一个使用Thread类建立线程的例子:
- package mythread;
- public class Thread1 extends Thread
- {
- public void run()
- {
- System.out.println(this.getName());
- }
- public static void main(String[] args)
- {
- System.out.println(Thread.currentThread().getName());
- Thread1 thread1 = new Thread1();
- Thread1 thread2 = new Thread1 ();
- thread1.start();
- thread2.start();
- }
- }
上面的代码建立了两个线程:thread1和thread2。上述代码中的005至008行是Thread1类的run方法。当在014和015行调用start方法时,系统会自动调用run方法。在007行使用this.getName()输出了当前线程的名字,由于在建立线程时并未指定线程名,因此,所输出的线程名是系统的默认值,也就是Thread-n的形式。在011行输出了主线程的线程名。
上面代码的运行结果如下:
main
Thread-0
Thread-1
从上面的输出结果可以看出,第一行输出的main是主线程的名子。后面的Thread-1和Thread-2分别是thread1和thread2的输出结果。
注意:任何一个Java程序都必须有一个主线程。一般这个主线程的名子为main。只有在程序中建立另外的线程,才能算是真正的多线程程序。也就是说,多线程程序必须拥有一个以上的线程。
Thread类有一个重载构造方法可以设置线程名。除了使用构造方法在建立线程时设置线程名,还可以使用Thread类的setName方法修改线程名。要想通过Thread类的构造方法来设置线程名,必须在Thread的子类中使用Thread类的public Thread(String name)构造方法,因此,必须在Thread的子类中也添加一个用于传入线程名的构造方法。下面的代码给出了一个设置线程名的例子:
- package mythread;
- public class Thread2 extends Thread
- {
- private String who;
- public void run()
- {
- System.out.println(who + ":" + this.getName());
- }
- public Thread2(String who)
- {
- super();
- this.who = who;
- }
- public Thread2(String who, String name)
- {
- super(name);
- this.who = who;
- }
- public static void main(String[] args)
- {
- Thread2 thread1 = new Thread2 ("thread1", "MyThread1");
- Thread2 thread2 = new Thread2 ("thread2");
- Thread2 thread3 = new Thread2 ("thread3");
- thread2.setName("MyThread2");
- thread1.start();
- thread2.start();
- thread3.start();
- }
在类中有两个构造方法:
第011行:public sample2_2(String who)
这个构造方法有一个参数:who。这个参数用来标识当前建立的线程。在这个构造方法中仍然调用Thread的默认构造方法public Thread( )。
第016行:public sample2_2(String who, String name)
这个构造方法中的who和第一个构造方法的who的含义一样,而name参数就是线程的名名。在这个构造方法中调用了Thread类的public Thread(String name)构造方法,也就是第018行的super(name)。
在main方法中建立了三个线程:thread1、thread2和thread3。其中thread1通过构造方法来设置线程名,thread2通过setName方法来修改线程名,thread3未设置线程名。
运行结果如下:
thread1:MyThread1
thread2:MyThread2
thread3:Thread-1
从上面的输出结果可以看出,thread1和thread2的线程名都已经修改了,而thread3的线程名仍然为默认值:Thread-1。thread3的线程名之所以不是Thread-2,而是Thread-1,这是因为在026行已经指定了thread2的Name,因此,启动thread3时就将thread3的线程名设为Thread-1。因此就会得到上面的输出结果。
注意:在调用start方法前后都可以使用setName设置线程名,但在调用start方法后使用setName修改线程名,会产生不确定性,也就是说可能在run方法执行完后才会执行setName。如果在run方法中要使用线程名,就会出现虽然调用了setName方法,但线程名却未修改的现象。
Thread类的start方法不能多次调用,如不能调用两次thread1.start()方法。否则会抛出一个IllegalThreadStateException异常。
Java多线程的相关机制
本文讲述了Java多线程的相关机制,分为线程的基本概念、线程的创建和启动、线程控制的基本方法和线程同步四个方面进行讲解。
一 线程的基本概念
线程是一个程序内部的顺序控制流.一个进程相当于一个任务,一个线程相当于一个任务中的一条执行路径.;多进程:在操作系统中能同时运行多个任务(程序);多线程:在同一个应用程序中有多个顺序流同时执行;Java的线程是通过java.lang.Thread类来实现的;JVM启动时会有一个由主方法(public static void main(){})所定义的线程;可以通过创建Thread的实例来创建新的线程;每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体,通过调用Thread类的start()方法来启动一个线程。
二 线程的创建和启动
可以有两种方式创建新的线程:
第一种:
1.定义线程类实现Runnable接口
2.Thread myThread = new Thread(target); //target为Runnable接口类型
3.Runnable中只有一个方法:public void run();用以定义线程运行体
4.使用Runnable接口可以为多个线程提供共享的数据
5.在实现Runnable接口的类的run()方法定义中可以使用Thread的静态方法public static Thread currentThread();获取当前线程的引用
第二种:
1.可以定义一个Thread的子类并重写其run方法如:
class MyThread extends Thread {
public void run() {...}
}
2.然后生成该类的对象:
MyThread myThread = new MyThread();
三 线程控制的基本方法
isAlive():判断线程是否还"活"着
getPriority():获得线程的优先级数值
setPriority():设置线程的优先级数值
Thread.sleep():将当前线程睡眠指定毫秒数
join():调用某线程的该方法,将当前线程与该线程"合并",即等待该线程结束,再恢复当前线程的运行
yield():让出cpu,当前线程进入就绪队列等待调度
wait():当前线程进入对象的wait pool
notify()/notifyAll():唤醒对象的wait pool中的一个/所有等待线程
四 线程同步
实现生产者消费者问题来说明线程问题,举例如下所示:
Java多线程学习总结(一)
本文是Java多线程总结的第一部分,讲到了线程的基本概念、线程的创建和启动、线程状态装换、线程控制基本方法、sleep方法等五个方面的内容。
一、线程的基本概念
简单的说:线程就是一个程序里不同的执行路径,在同一个时间点上cpu只会有一个线程在执行,Java里的多线程是通过java.lang.Thread类来实现的,每个线程都拥有自己独立的方法栈空间。
二、java线程的创建和启动
第一种
定义线程类实现Runnable接口:
Thread myThread = new Thread(target) //target为Runnable接口类型
Runnable中只有一个方法:
public void run();用以定义线程运行体
第二种
可以定义一个Thread的子类并重写其run方法:
clas MyThread extends Thread{
public void run(){}
}
线程类必须通过执行Thread的start()方法启动一个新的线程,如果调用run()方法是属于方法的调用,不会启动一个新的线程,推荐使用第一种方式创建线程,使用接口较为灵活。
三、线程状态装换
调用线程start()方法时,线程进入就绪状态,Cpu分配时间片,线程进入运行状态,时间片结束,run()方法未执行完,线程进入阻塞状态。
四、线程控制基本方法
isAlive() //判断线程是否还“活着”,即线程是否还未终止
getPriority() //获得线程的优先级数值
setPriority() //设置线程的优先级指数
Thread.sleep() //静态方法,将当前线程睡眠指定毫秒数
join() //调用某线程的该方法,将当前线程与该线程合并,
//即等待该线程结束,再回复当前线程的运行。
yield() //让出CPU,当前线程进入就绪状态等待调度
interrupt() //中断线程
wait() //当前线程进入对象的wait pool
notify()/all //唤醒对象的wait pool中的一个/所有等待线程
五、sleep方法
Thread的静态方法
public static void sleep(long millis)throws InterruptedException
必须对异常进行捕捉
Thread.currentThread(); //拿到当前线程
Java多线程学习总结(二)
本文是Java多线程学习总结的第二部分,讲到了interrupt方法一种让线程退出的方式、join和yield方法、线程优先级别 、线程优先级别 、线程同步、生产者消费者问题五个方面的内容。
一、interrupt方法一种让线程退出的方式。
- import java.util.*;
- public class TestInterrupt{
- public static void main(String[] args){
- MyThread t = new MyThread();
- t.start();
- try{Thread.sleep(10000);}
- catch(InterruptedException i){}
- t.interrupt();
- }
- }
- class MyThread extends Thread{
- public void run(){
- while(true){
- try{
- System.out.println("------"+new Date()+"-----");
- Thread.sleep(1000);
- }catch(InterruptedException i){
- return;
- }
- }
- }
- }
二、join和yield方法
t.join(); //t的run()方法完才会继续执行当前线程方法体
//也就是两个线程变成了一个线程
t.yield(); //暂停当前正在执行的线程对象,并执行其他线程。方法为静态
//哪个线程体执行此方法,哪个线程让步
- public class TestYield {
- public static void main(String[] args) {
- MyThread3 t1 = new MyThread3("t1");
- MyThread3 t2 = new MyThread3("t2");
- t1.start(); t2.start();
- }
- }
- class MyThread3 extends Thread {
- MyThread3(String s){super(s);}
- public void run(){
- for(int i =1;i<=100;i++){
- System.out.println(getName()+": "+i);
- if(i%10==0){
- yield();
- }
- }
- }
- }
三、线程优先级别
线程的优先级用数字表示,范围从1到10,一个线程的缺省优先级为5.
Thread.MAX_PRIORITY=1
Thread.MIN_PRIORITY=10
Thread.NORM_PRIORITY=5
例:t.setPriority(Thread.NORM_PRIORITY+3);
四、线程同步
1.同步代码块
synchronized(this){ //在执行代码块过程中,不会被其他线程打断
...
}
public sunchronized void method //执行此方法时,当前对象被锁定
在Java语言中,引入了对象互斥锁的概念,保证共享数据操作的完整性,每个对象 都对应一个可称为"互斥锁"的标记,这个标记保证在任一时刻,只能有一个线程访 问该对象。
2.线程死锁
- public class TestDeadLock implements Runnable {
- public int flag = 1;
- static Object o1 = new Object(), o2 = new Object();
- public void run() {
- System.out.println("flag=" + flag);
- if(flag == 1) {
- synchronized(o1) {
- try {
- Thread.sleep(500);
- } catch (Exception e) {
- e.printStackTrace();
- }
- synchronized(o2) {
- System.out.println("1");
- }
- }
- }
- if(flag == 0) {
- synchronized(o2) {
- try {
- Thread.sleep(500);
- } catch (Exception e) {
- e.printStackTrace();
- }
- synchronized(o1) {
- System.out.println("0");
- }
- }
- }
- }
- public static void main(String[] args) {
- TestDeadLock td1 = new TestDeadLock();
- TestDeadLock td2 = new TestDeadLock();
- td1.flag = 1;
- td2.flag = 0;
- Thread t1 = new Thread(td1);
- Thread t2 = new Thread(td2);
- t1.start();
- t2.start();
- }
- }
五、生产者消费者问题
- public class ProducerConsumer {
- public static void main(String[] args) {
- SyncStack ss = new SyncStack();
- Producer p = new Producer(ss);
- Consumer c = new Consumer(ss);
- new Thread(p).start();
- new Thread(p).start();
- new Thread(p).start();
- new Thread(c).start();
- }
- }
- class WoTou {
- int id;
- WoTou(int id) {
- this.id = id;
- }
- public String toString() {
- return "WoTou : " + id;
- }
- }
- class SyncStack { //栈实现
- int index = 0;
- WoTou[] arrWT = new WoTou[6]; //相当于装物品的篮子
- public synchronized void push(WoTou wt) { //生产物品,线程安全
- while(index == arrWT.length) { //当篮子满了线程等待
- try {
- this.wait();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- this.notifyAll(); //开始生产时,叫醒等待的其他线程开始消费
- arrWT[index] = wt;
- index ++;
- }
- public synchronized WoTou pop() { //消费物品,线程安全
- while(index == 0) { //如果篮子空了
- try {
- this.wait(); //线程等待,等待生产者开始
- //生产,叫醒此线程
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- this.notifyAll(); //消费时喊醒生产者生产
- index--;
- return arrWT[index];
- }
- }
- class Producer implements Runnable { //生产者类
- SyncStack ss = null;
- Producer(SyncStack ss) {
- this.ss = ss;
- }
- public void run() {
- for(int i=0; i<20; i++) { //生产20个
- WoTou wt = new WoTou(i);
- ss.push(wt);
- System.out.println("生产了:" + wt);
- try {
- Thread.sleep((int)(Math.random() * 200));
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
- class Consumer implements Runnable {
- SyncStack ss = null;
- Consumer(SyncStack ss) {
- this.ss = ss;
- }
- public void run() {
- for(int i=0; i<20; i++) { //消费20个
- WoTou wt = ss.pop();
- System.out.println("消费了: " + wt);
- try {
- Thread.sleep((int)(Math.random() * 1000));
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
初学Java多线程:join方法的使用
初学Java多线程系列的本章讲述join方法的使用。join方法的功能就是使异步执行的线程变成同步执行。
在上面的例子中多次使用到了Thread类的join方法。我想大家可能已经猜出来join方法的功能是什么了。对,join方法的功能就是使异步执行的线程变成同步执行。也就是说,当调用线程实例的start方法后,这个方法会立即返回,如果在调用start方法后后需要使用一个由这个线程计算得到的值,就必须使用join方法。如果不使用join方法,就不能保证当执行到start方法后面的某条语句时,这个线程一定会执行完。而使用join方法后,直到这个线程退出,程序才会往下执行。下面的代码演示了join的用法。
- package mythread;
- public class JoinThread extends Thread
- {
- public static int n = 0;
- static synchronized void inc()
- {
- n++;
- }
- public void run()
- {
- for (int i = 0; i < 10; i++)
- try
- {
- inc();
- sleep(3); // 为了使运行结果更随机,延迟3毫秒
- }
- catch (Exception e)
- {
- }
- }
- public static void main(String[] args) throws Exception
- {
- Thread threads[] = new Thread[100];
- for (int i = 0; i < threads.length; i++) // 建立100个线程
- threads[i] = new JoinThread();
- for (int i = 0; i < threads.length; i++) // 运行刚才建立的100个线程
- threads[i].start();
- if (args.length > 0)
- for (int i = 0; i < threads.length; i++) // 100个线程都执行完后继续
- threads[i].join();
- System.out.println("n=" + JoinThread.n);
- }
- }
在例程2-8中建立了100个线程,每个线程使静态变量n增加10。如果在这100个线程都执行完后输出n,这个n值应该是1000。
1. 测试1
使用如下的命令运行上面程序:
- java mythread.JoinThread
程序的运行结果如下:
n=442
这个运行结果可能在不同的运行环境下有一些差异,但一般n不会等于1000。从上面的结果可以肯定,这100个线程并未都执行完就将n输出了。
2. 测试2
使用如下的命令运行上面的代码:
在上面的命令行中有一个参数join,其实在命令行中可以使用任何参数,只要有一个参数就可以,这里使用join,只是为了表明要使用join方法使这100个线程同步执行。
程序的运行结果如下:
n=1000
无论在什么样的运行环境下运行上面的命令,都会得到相同的结果:n=1000。这充分说明了这100个线程肯定是都执行完了,因此,n一定会等于1000。
Java多线程编程的常见陷阱
本文介绍了Java多线程编程中的常见陷阱,如在构造函数中启动线程,不完全的同步,在使用某个对象当锁时,改变了对象的引用,导致同步失效等等。
1、在构造函数中启动线程
我在很多代码中都看到这样的问题,在构造函数中启动一个线程,类似这样:
- public class A{
- public A(){
- this.x=1;
- this.y=2;
- this.thread=new MyThread();
- this.thread.start();
- }
- }
这个会引起什么问题呢?如果有个类B继承了类A,依据java类初始化的顺序,A的构造函数一定会在B的构造函数调用前被调用,那么thread线程也将在B被完全初始化之前启动,当thread运行时使用到了类A中的某些变量,那么就可能使用的不是你预期中的值,因为在B的构造函数中你可能赋给这些变量新的值。也就是说此时将有两个线程在使用这些变量,而这些变量却没有同步。
解决这个问题有两个办法:将A设置为final,不可继承;或者提供单独的start方法用来启动线程,而不是放在构造函数中。
2、不完全的同步
都知道对一个变量同步的有效方式是用synchronized保护起来,synchronized可能是对象锁,也可能是类锁,看你是类方法还是实例方法。但是,当你将某个变量在A方法中同步,那么在变量出现的其他地方,你也需要同步,除非你允许弱可见性甚至产生错误值。类似这样的代码:
- class A{
- int x;
- public int getX(){
- return x;
- }
- public synchronized void setX(int x)
- {
- this.x=x;
- }
- }
x的setter方法有同步,然而getter方法却没有,那么就无法保证其他线程通过getX得到的x是最新的值。事实上,这里的setX的同步是没有必要的,因为对int的写入是原子的,这一点JVM规范已经保证,多个同步没有任何意义;当然,如果这里不是int,而是double或者long,那么getX和setX都将需要同步,因为double和long都是64位,写入和读取都是分成两个32位来进行(这一点取决于jvm的实现,有的jvm实现可能保证对long和double的read、write是原子的),没有保证原子性。类似上面这样的代码,其实都可以通过声明变量为volatile来解决。
3、在使用某个对象当锁时,改变了对象的引用,导致同步失效。
这也是很常见的错误,类似下面的代码:
- synchronized(array[0])
- {
- ......
- array[0]=new A();
- ......
- }
同步块使用array[0]作为锁,然而在同步块中却改变了array[0]指向的引用。分析下这个场景,第一个线程获取了array[0]的锁,第二个线程因为无法获取array[0]而等待,在改变了array[0]的引用后,第三个线程获取了新的array[0]的锁,第一和第三两个线程持有的锁是不一样的,同步互斥的目的就完全没有达到了。这样代码的修改,通常是将锁声明为final变量或者引入业务无关的锁对象,保证在同步块内不会被修改引用。
4、没有在循环中调用wait()。
wait和notify用于实现条件变量,你可能知道需要在同步块中调用wait和notify,为了保证条件的改变能做到原子性和可见性。常常看见很多代码做到了同步,却没有在循环中调用wait,而是使用if甚至没有条件判断:
- synchronized(lock)
- {
- if(isEmpty()
- lock.wait();
- }
对条件的判断是使用if,这会造成什么问题呢?在判断条件之前可能调用notify或者notifyAll,那么条件已经满足,不会等待,这没什么问题。在条件没有满足,调用了wait()方法,释放lock锁并进入等待休眠状态。如果线程是在正常情况下,也就是条件被改变之后被唤醒,那么没有任何问题,条件满足继续执行下面的逻辑操作。问题在于线程可能被意外甚至恶意唤醒,由于没有再次进行条件判断,在条件没有被满足的情况下,线程执行了后续的操作。意外唤醒的情况,可能是调用了notifyAll,可能是有人恶意唤醒,也可能是很少情况下的自动苏醒(称为“伪唤醒”)。因此为了防止这种条件没有满足就执行后续操作的情况,需要在被唤醒后再次判断条件,如果条件不满足,继续进入等待状态,条件满足,才进行后续操作。
- synchronized(lock)
- {
- while(isEmpty()
- lock.wait();
- }
没有进行条件判断就调用wait的情况更严重,因为在等待之前可能notify已经被调用,那么在调用了wait之后进入等待休眠状态后就无法保证线程苏醒过来。
5、同步的范围过小或者过大。
同步的范围过小,可能完全没有达到同步的目的;同步的范围过大,可能会影响性能。同步范围过小的一个常见例子是误认为两个同步的方法一起调用也是将同步的,需要记住的是Atomic+Atomic!=Atomic。
- Map map=Collections.synchronizedMap(new HashMap());
- if(!map.containsKey("a")){
- map.put("a", value);
- }
这是一个很典型的错误,map是线程安全的,containskey和put方法也是线程安全的,然而两个线程安全的方法被组合调用就不一定是线程安全的了。因为在containsKey和put之间,可能有其他线程抢先put进了a,那么就可能覆盖了其他线程设置的值,导致值的丢失。解决这一问题的方法就是扩大同步范围,因为对象锁是可重入的,因此在线程安全方法之上再同步相同的锁对象不会有问题。
- Map map = Collections.synchronizedMap(new HashMap());
- synchronized (map) {
- if (!map.containsKey("a")) {
- map.put("a", value);
- }
- }
注意,加大锁的范围,也要保证使用的是同一个锁,不然很可能造成死锁。 Collections.synchronizedMap(new HashMap())使用的锁是map本身,因此没有问题。当然,上面的情况现在更推荐使用ConcurrentHashMap,它有putIfAbsent方法来达到同样的目的并且满足线程安全性。
同步范围过大的例子也很多,比如在同步块中new大对象,或者调用费时的IO操作(操作数据库,webservice等)。不得不调用费时操作的时候,一定要指定超时时间,例如通过URLConnection去invoke某个URL时就要设置connect timeout和read timeout,防止锁被独占不释放。同步范围过大的情况下,要在保证线程安全的前提下,将不必要同步的操作从同步块中移出。
6、正确使用volatile
在jdk5修正了volatile的语义后,volatile作为一种轻量级的同步策略就得到了大量的使用。volatile的严格定义参考jvm spec,这里只从volatile能做什么,和不能用来做什么出发做个探讨。
volatile可以用来做什么?
1)状态标志,模拟控制机制。常见用途如控制线程是否停止:
- private volatile boolean stopped;
- public void close(){
- stopped=true;
- }
- public void run(){
- while(!stopped){
- //do something
- }
- }
前提是do something中不会有阻塞调用之类。volatile保证stopped变量的可见性,run方法中读取stopped变量总是main memory中的最新值。
2)安全发布,如修复DLC问题。
- private volatile IoBufferAllocator instance;
- public IoBufferAllocator getInsntace(){
- if(instance==null){
- synchronized (IoBufferAllocator.class) {
- if(instance==null)
- instance=new IoBufferAllocator();
- }
- }
- return instance;
- }
3)开销较低的读写锁
- public class CheesyCounter {
- private volatile int value;
- public int getValue() { return value; }
- public synchronized int increment() {
- return value++;
- }
- }
synchronized保证更新的原子性,volatile保证线程间的可见性。
volatile不能用于做什么?
1)不能用于做计数器
- public class CheesyCounter {
- private volatile int value;
- public int getValue() { return value; }
- public int increment() {
- return value++;
- }
- }
因为value++其实是有三个操作组成的:读取、修改、写入,volatile不能保证这个序列是原子的。对value的修改操作依赖于value的最新值。解决这个问题的方法可以将increment方法同步,或者使用AtomicInteger原子类。
2)与其他变量构成不变式
一个典型的例子是定义一个数据范围,需要保证约束lower< upper。
- public class NumberRange {
- private volatile int lower, upper;
- public int getLower() { return lower; }
- public int getUpper() { return upper; }
- public void setLower(int value) {
- if (value > upper)
- throw new IllegalArgumentException();
- lower = value;
- }
- public void setUpper(int value) {
- if (value < lower)
- throw new IllegalArgumentException();
- upper = value;
- }
- }
尽管讲lower和upper声明为volatile,但是setLower和setUpper并不是线程安全方法。假设初始状态为(0,5),同时调用setLower(4)和setUpper(3),两个线程交叉进行,最后结果可能是(4,3),违反了约束条件。修改这个问题的办法就是将setLower和setUpper同步:
- public class NumberRange {
- private volatile int lower, upper;
- public int getLower() { return lower; }
- public int getUpper() { return upper; }
- public synchronized void setLower(int value) {
- if (value > upper)
- throw new IllegalArgumentException();
- lower = value;
- }
- public synchronized void setUpper(int value) {
- if (value < lower)
- throw new IllegalArgumentException();
- upper = value;
- }
- }