Java 多线程编程总结

18 Nov 2016 , 5947 words

Java 程序中总是会有的 main() 运行在这个程序的主线程上。但有的时候可以让 Java 程序同时多运行几个线程从而提高运行效率,也就是多线程编程。这一点在 GUI 编程中尤其必要,常见的 Swing 框架就是利用了多线程:在这个框架中,负责 GUI 的线程 EDT(Event Dispatcher Thread)和负责实际运算的线程是分离的,这样才能保证即使是程序繁忙运行时, GUI 也能随时响应用户的操作而不至于冻结。 这里说的是线程 而非 进程,JVM 一般都是单进程的。

最底层:Thread 子类和 Runnable 接口

Java 中创建一个新线程的基本思路是通过 Java.lang.Thread 或 Java.lang.Runnable。其中 Thread 是类,Runnable 是接口。

Thread 子类

Thread 子类的思路比较直观:

public class test{
	public static void main(String[] args){
		(new myThreadClass()).start();
	}
}
//class that extends Thread
class myThreadClass extends Thread{
	public void run(){
		System.out.println("I'm running in a thread named " + this.getName());
	}
}

这里定义了一个 myThreadClass,它是 Thread 的 subclass,其中包含了一个 run() 方法。要运行的内容只要写在 run() 中即可。run() 本身并没有什么,真正在背后创建线程的是 Thread 自带的 start() 方法:

当主线程中创建一个 myThreadClass 实例并调用其 start() 方法时,Java 就会自动创建一个新的线程并运行 run()

p.s. 获取当前进程的方法是通过 Thread 类方法 Thread.currentThread()

Runnable 接口

Runnable 接口也能用来创建、运行新线程:

public class test{
	public static void main(String[] args){
		Thread t = new Thread(new Runnable(){
			public void run(){
				System.out.println("Another new thread!");
			}
		});
		t.start();
	}
}

这里并没有专门创造一个 Thread 的子类,而是直接新建了 Thread 的实例 t。同时新建了一个匿名的 Runnable 然后将其作为 t 的 constructor 的参数。之后如果主进程调用 t.start() 跟上边例子里效果完全一样。

Runnable 往往是更好的选择

Runnable 接口比直接创建 Thread 子类更加灵活。因为一个类只可以继承一个父类,如果我们新建的类 myThreadClass 继承了 Thread 就意味着它不能继承其他类。而接口实现的数量则没有限制,所以下边的例子里显然我们能做的更多:

public class test{
	public static void main(String[] args){
		drawApple t = new drawApple();
		(new Thread(t)).start();
	}
}

class drawApple extends drawFruit implements Runnable{
	public void drawMyFruit(){
	   //extends its parent class as usual
		super.drawMyFruit();
		System.out.println("Wait, I can draw a delicious apple!");
	}
	
	public void run(){
	   //implements run() method of Runnable interface to run on new thread
		drawMyFruit();
		System.out.println("What's more, I can draw it in a new thread!");
	}
	 
}

class drawFruit{
	public void drawMyFruit(){
		System.out.println("I can only draw a basic fruit...");
	}
}

输出结果为:

I can only draw a basic fruit...
Wait, I can draw a delicious apple!
What's more, I can draw it in a new thread!

干预调度的几个基本方法

Runnable 这个词,同时是也是线程可能的状态之一:可运行。

顾名思义,当一个 Runnable 的线程被创造,它并不一定就开始 running了,而只是处在 runnable 的状态。 具体 JVM 会把哪个线程排在前排在后真正运行,取决于很多因素,基本可以理解为一个黑箱。

有以下几种基本方法可以强制停止正在运行中的线程,来给其他线程腾出资源。

Thread.sleep()

Thread 的类方法 sleep(long millis)(参数即睡眠的长度,以 milli-second 为单位),可以让当前正在运行的线程进入睡眠状态,在参数给定时长后醒来恢复可运行状态。 睡眠状态中途可以被 interrupt 唤醒

Thread.yield()

这个类方法可以让当前正在运行的线程从 running 变回 runnable 状态,从而可能让其他线程开始运行—— 然而 JVM 也可能会立刻又把这个线程调度到最前边去。

###join() 这个相比前两个 Thread 类方法更好用。它的作用 doc 里说的简洁明了:

Waits for this thread to die.

也就是说, t.join() 的作用就是,让当前正在运行的线程进入等待,一直等到线程t挂掉再继续。

这个方法也接受参数 join(long millis),millis 即最长等待的时间,超过这个时间就不再等待变为可运行。 等待状态中途可以被 interrupt 唤醒

InterruptedException

前边提到的 Thread.sleep()join() 都可以被 interrupt 唤醒。interrupt 就是让某个线程停止当前工作, t.interrupt() 即让线程 t 停止,如果此时 t 正在睡眠或等待,则将被唤醒并收到一个 InterruptException。因此常见的处理方式是:

try{
	t.sleep(5000);
} catch(InterruptedException){
	System.out.println("How dare you wake me up!");
	return;
}

同步 Synchronized

多个线程「同时」运行会带来「Memory Consistency」问题。比如 2 个线程同时对 a = 0 做简单的 a += 1 运算,结果本应等于 2。

实际运行时,却可能出现这样的情况:

一个线程读取了 a 的值 (0),于是开始计算 0 + 1。 此时另一个线程来读取了 a 的值,会发现此时 a 的值还是 0,于是它也开始计算 0 + 1。 最终两个线程各自把计算结果 1 写回 a, a 的结果为 1,而不是 2。

为了避免这样的错误,就需要保证:读取 a, 计算 a + 1,写回 a, 这三件事同时只有一个线程在做。

Java 提供了 synchronized 同步来解决这个问题:

public synchronized int getAPlusOne(){
    a += 1;
    return a;
} 

也可以把 synchonized 放在一个方法中,只有部分语句同步。

public int getAPlusOne(){
    synchronized(this){
        a += 1;
        return a;
    }
}

这里需要理解的是 synchronized 本质是利用了 Java 中每个对象都有的内在锁 (intrinsic lock),一旦一个线程获得了一个对象的内在锁,其他线程就不能再访问这个对象了(本线程的其他方法不受影响,依然可以访问,所以这种锁称为 reentrant lock),直到 synchronized 涵盖的代码块运行结束,释放对象的内在锁。

关于同步要注意以下几点:

  1. 变量和类是不能同步的,只能同步方法,且不能是 abstract 方法。
  2. 线程睡眠时也不会释放锁。因此应避免在 synchronized 的代码块中使用 sleep()。
  3. 同一个线程可以获得多个对象的锁。
  4. 如果要同步的是一个类,那么获得的锁也是这个类 class 的,而不是任何一个实例 instance 的。实例与类之间的锁互不相干。

死锁

如果一个线程 A 要访问的对象当前被另一个线程 B 获得了锁,那么 A 就会 block, 等待 B 释放锁。

这是一个直观的解决方案,但为同步带来了新的问题,比如不小心会出现的死锁(deadlock):两个线程互相都在等待对方释放自己的锁,从而僵持住了。上边的例子中,如果线程 B 接下来要执行的任务需要获得某个对象的锁,而这个锁恰好又在线程 A 中,就成了死锁。因为 A 此刻正握住这个对象的锁等待着 B 执行完成,好去获得 B 此刻正占有的锁。

Java doc 中的这个死锁程序就有意思,它模拟了两个朋友鞠躬,都在等待对方,无法继续。

lock,比同步更灵活的外部锁

前边说了,synchronized 同步的本质是获得一个对象的内在锁,内在锁是 Java 中每个对象都自带的。

同时 Java 还提供了外部锁 lock 接口,更为强大灵活。基本语法:

Lock lk = new ReentrantLock();
lk.lock();
...
lk.unlock();

用它改写前边的 a += 1 的例子:

private Lock lk = new ReentrantLock();

public int getAPlusOne(){
    lk.lock();
    try{
        a += 1;
        return a
    } finally{
        lk.unlock();
    }
}

这里一个良好的习惯是把解锁放在 fianally 当中,这样即使前边的运算中出现 exception,也不会一直锁着对象不放。

lock 真正强大灵活的在于基本锁之外的几种锁。其完整的 API 如下:

public interface Lock{
	void lock();
	void lockInterruptibly(); //可以被 interrupt 的锁
	boolean tryLock(); //尝试锁,返回是否成功
	boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //尝试锁,如果不马上成功则等待给定时间
	void unlock();
	Condition newCondition();
}

SwingWorker

前边说了,Swing 框架下的 GUI 程序天生都是多线程的。因此 Swing 也提供了更方便的管理多线程的方式,其中就包括 SwingWorker

在这里先插一点。Swing GUI 程序的一个基本准则就是: 只有 EDT 一个线程与 GUI 发生交互,实际运算不直接干涉 GUI。

因此经常看到以下代码来初始化 GUI:

SwingUtilities.invokeLater(new Runnable() { 
    public void run() {
        ...// Code to be executed by the EDT 
    } 
});

这段代码把绘制 GUI 的工作放到一个 Runnable 里再由 SwingUtilities.invokeLater调用,保证让 EDT 绘制界面。

SwingWorker<T,V>

SwingWorker 是一个抽象类,它的功能总结起来就是:提供一个简便的方法来在 Swing GUI 程序中运用背景线程,保证 EDT 专注于 GUI 部分,从而保持程序本身响应的流畅。

其基本运用方法如下:

public mySwingWorker extends SwingWorker<T,V>{
    @Override
    protected T doInBackground(){
        //编程者必须实现的方法
        //真正负责运算的代码,框架会在背景线程上运行它
        //T 即该方法最终返回的类型
        //
        //可以在此方法中调用 publich(V),其作用为在运行过程中返回类型为 V 的值给下边的 process() 处理
    }
    
    @Override
    protected void process(List<V> chunks){
        //会在EDT上运行此方法
        //每次都会获得最近 publish() 返回的 V 值组成的 list,作为参数
    }
    
    @Override
    protected void done(){
        // EDT 在 doInBackground() 运行结束后运行
        // 一般会调用 get() 获得 doInBackground() 返回的类型为 T 的值
    }
}

本来是复习笔记,写到这里时候就考完试了,不想再往下写了…… 关于 Java 并发还需要了解的概念是原子操作(替代锁)、ExecutorFutureTask 等等等。嘛, 总之挺麻烦就对了……

假期开始。

carol