超详细JAVA基础——多线程
一、引言
多线程使得程序中的多个任务可以同时执行
Java的重要功能之一就是内部支持多线程——在许多程序设计语言中,多线程都是通过调用依赖于系统的过程或函数来实现的。
二、线程的概念
一个程序可能包含多个可以同时运行的任务。线程是指一个任务从头到尾的执行流程。
在一个java程序中可以并发地启动多个线程。这些线程可以在多处理器系统上并行运行,也可以在单处理器系统上并发执行。并发是指多个任务在宏观上同时运行,微观上不同时运行;并行是指多个任务在宏观和微观上都同时运行。
多线程可以使程序的反应更快,交互性更强,执行效率更高。比如某任务在等待用户的输入操作时,系统便可以将CPU资源分配给其他任务。因此,即使在单处理器系统上,多线程程序的运行速度也也比单线程更快。
三、创建任务和线程
可以在程序中创建附加的线程以执行并发任务。在Java中,每个任务都是Runnable接口的一个实例,也称为可运行对象(runnable object)。线程本质上讲就是便于任务执行的对象。
一个任务类必须实现Runnable接口。任务必须从线程运行。
3.1 步骤一:创建类,实现Runnable接口
创建一个实现循环打印字符功能的类PrintChar,并实现Runnable接口。类的数据域有char c和int n,分别为打印的目标的字符和打印的次数。实现run方法告诉系统该线程将如何运行。类的定义代码如下:
public class PrintChar implements Runnable{
private char c;
private int n;
public PrintChar() {
}
public PrintChar(char c, int n) {
this.c = c;
this.n = n;
}
@Override
public void run() {
for(int i = 0; i < n; i ++) {
System.out.print(c + " ");
}
}
}
3.2 创建一个任务
通过类的构造方法生成类的实例化对象,这便是创造了一个任务。使用下面的语句创建PrintChar的任务:
PrintChar printChar1 = new PrintChar('a', 1000);
3.3 创建任务的线程
任务必须在线程中执行。Thread类包括创建线程的构造方法以及控制线程的很多有用的方法。使用下面的语句创建任务的线程:
Thread thread1 = new Thread(printChar1);
然后调用start()方法告诉Java虚拟机该线程准备运行,如下所示:
thread1.start();
3.4 编写程序
根据以上步骤创建一个Java程序,它创建了三个任务以及三个运行这些任务的线程:
- 第一个任务printclass1打印字符 a 10次。
- 第二个任务printclass2打印字符 b 10次。
- 第三个任务printclass3打印字符 c 10次。
代码如下:
public class TaskThreadTest{
public static void main(String[] args) {
PrintChar printChar1 = new PrintChar('a', 100);
PrintChar printChar2 = new PrintChar('b', 100);
PrintChar printChar3 = new PrintChar('c', 100);
Thread thread1 = new Thread(printChar1);
Thread thread2 = new Thread(printChar2);
Thread thread3 = new Thread(printChar3);
thread1.start();
thread2.start();
thread3.start();
}
}
该程序创建了三个任务。为了同时运行它们,创建三个线程。调用start()方法启动一个线程,它会导致任务中的run()方法被执行。当run()方法执行完毕,线程就终止。
截取一小段输出结果如下图所示:
从输出结果可以看到,字符a、b、c的打印是没有顺序的,展示了三个进程并发运行的效果。
注意:任务中的run()方法指明如何完成这个任务。Java虚拟机会自动调用该方法,无需特地调用它。
四、Thread类
Thread类包含为任务而创建的线程的构造方法,以及控制线程的方法。
Thread类的方法和描述如下表所示:
方法 | 描述 |
---|---|
+Thread() | 创建一个空的线程 |
+Thread(task: Runnable) | 为指定的任务创建一个线程 |
+star():void | 开始一个线程导致JVM 调用run()方法 |
+isAlive(): boolean | 测试线程当前是否在运行 |
+setPriority(p:int): void | 为该线程指定优先级p(取值从1到10) |
+join(): void | 等待该线程结束 |
+sleep(millis: long): void | 让一个线程休眠指定时间,以毫秒为单位 |
+yield(): void | 引发一个线程暂停并允许其他线程执行 |
+interrupt(): void | 中断该线程 |
- 可以使用yield()方法为其他线程临时让出 CPU时间。例如,将PrintChar类的run()方法的代码做如下修改:
@Override
public void run() {
for(int i = 0; i < n; i ++) {
System.out.print(c + " ");
Thread.yield();
}
}
每次打印一个字符后,printchar任务的线程会让出时间给其他线程。
- 方法sleep(long mills)可以将该线程设置为休眠以确保其他线程的执行,休眠时间为指定的毫秒数。例如,PrintChar类的run方法代码可以修改如下:
@Override
public void run() {
for(int i = 0; i < n; i ++) {
System.out.print(c + " ");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
每打印一个字符之后,线程就休眠1秒。
五、线程池
通过上面的学习,我们学会了通过实现Runnable接口来定义一个任务类:
public class TaskClass implements Runnable {
//通过构造器创建任务对象
public TaskClass(...) {...}
//在run方法中定义任务流程
@Override
public void run() {...}
}
以及创建一个线程来运行任务:
Thread thread = new Thread(new TaskClass(...));
thread.start();
该方法对单一任务的执行是很方便的,但是由于必须为每个任务创建一个线程,因此对大量的任务而言是不够高效的,为每个任务开始一个新线程可能会限制吞吐量并且造成性能降低。
这就引出了线程池的概念。线程池是管理并发执行任务个数的理想方法。Java提供Executor接口来执行线程池中的任务,提供ExecutorService接口来管理和控制任务。其中,ExecutorService是 Executor的子接口。两者关系如下图所示:
可以使用Executors类中的静态方法创建一个ExecutorService对象,如下图所示:
- newFixedThreadPool方法在池中创建固定数目的线程。如果线程完成了任务的执行,它可以被重新使用以执行另外一个任务。
- 如果线程池中所有的线程都不是处于空闲状态,而且有任务在等待执行,那么在关闭之前,如果由于一个错误终止了一个线程,就会创建一个新线程来替代它。
- 如果线程池中所有的线程都不是处于空闲状态,而且有任务在等待执行,那么newCachedThreadPool()方法就会创建一个新线程。
- 如果缓冲池中的线程在60秒内都没有被使用就该终止它。对许多小任务而言,一个缓冲池已经足够。
因此,我们可以使用Executors类中的静态方法newFixedThreadPool() 返回ExecutorService类型的线程池对象,使用对象的execute() 方法向线程池中添加任务。最后使用close() 方法关闭线程池。使用线程池重写先前的程序,代码如下:
public class TestThreadPool {
public static void main(String[] args) {
//得到一个线程数为3的线程池对象executor
ExecutorService executor = Executors.newFixedThreadPool(3);
//通过从Executor接口继承的execute方法,向进程池对象executor中添加任务
executor.execute(new PrintChar('a', 100));
executor.execute(new PrintChar('b', 100));
executor.execute(new PrintChar('c', 100));
//关闭进程池
executor.shutdown();
}
}
如果将进程池中的线程数量设为1,那么这三个任务将会顺序执行,结果就是先输出100次字符a,再输出100次字符b,最后输出100次字符c。
如果将第六行代码改为ExecutorService executor = Executors.newCachedThreadPool();,程序将会为每个等待的任务创建一个新线程,所有的任务都将并发执行。
六、线程同步
如果一个共享资源同时被多个线程访问,那么可能会遭到破坏。下面的程序将作为例子说明这个问题:
public class AccountWithoutSync {
//创建一个账户
private static Account account = new Account();
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
for(int i = 0; i < 100; i ++) {
executor.execute(new AddAPennyTask());
}
executor.shutdown();
while(!executor.isTerminated()) {}
System.out.println(account.getBalance());
}
//AddAPennyTask类实现Runnable接口,在run方法中定义运行内容为向账户加一块钱
private static class AddAPennyTask implements Runnable{
@Override
public void run() {
try {
account.deposit(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
private static class Account {
private int balance = 0;
public int getBalance() {
return balance;
}
public void deposit(int num) throws InterruptedException {
int newBalance = balance + num;
Thread.sleep(5);
balance = newBalance;
}
}
}
10~12行代码将100个任务添加到了线程池,每个任务都实现了向账户内添加一块钱的操作。那么正常情况下,account.getBalance()方法返回的值应该是100。但是执行该代码得到的结果如下:
那么为什么会出现这种情况呢,下面给出一个可能得场景:
在线程1中,任务获得的balance为0。在线程1休眠的时候,线程2的任务获得的balance依然为0,因为此时线程1还没有将balance的值更新。时间线结束的时候,线程2为balance赋值1,覆盖了线程1的赋值。
很明显,问题就是任务1和任务2以一种会引起冲突的方式访问一个公共资源。这是多线程程序中的一个普遍问题,称为竞争状态(race condition)。如果一个类的对象在多线程程序中没有导致竞争状态,则称这样的类为线程安全的 (thread-safe)。如上例所示,Acount类不是线程安全的。
6.1 synchronized关键字
为避免竞争状态,应该防止多个线程同时进入程序的某一特定部分,程序中的这部分称为临界区(criticalregion)。
Account类中的deposit方法是临界区,可以使用synchronized关键字同步方法,以便一次只有一个线程访问这个方法。下面介绍一种能够解决上述问题的方法:
在Account类中的deposit 方法中添加关键字synchronized,使Account类成为线程安全的,如下所示:
public synchronized void deposit(int num) throws InterruptedException {
int newBalance = balance + num;
Thread.sleep(5);
balance = newBalance;
}
原理:一个同步方法在执行之前需要加锁。锁是一种实现资源排他使用的机制。对于实例方法,要给调用该方法的对象加锁。对于静态方法,要给这个类加锁。如果一个线程调用一个对象上的同步实例方法(静态方法),首先给该对象(类)加锁,然后执行该方法,最后解锁。在解锁之前,另一个调用那个对象(类)中方法的线程将被阻塞,直到解锁。
6.2 同步语句
下面介绍另一种解决竞争状态问题的方法——同步语句。
当执行方法中某一个代码块时,同步语句不仅可用于对 this对象加锁,而且可用于对任何对象加锁。这个代码块称为同步块(synchronized block)。同步语句的一般形式如下所示:
synchronized (expr) {
statement;
}
表达式expr求值结果必须是一个对象的引用。如果对象已经被另一个线程锁定,则在解锁之前,该线程将被阻塞。当获准对一个对象加锁时,该线程执行同步块中的语句,然后解除给对象所加的锁。同步语句允许设置同步方法中的部分代码,而不必是整个方法,这大大增强了程序的并发能力。
对程序中AddAPennyTask类的run方法进行如下修改:
@Override
public void run() {
try {
synchronized (account) {
account.deposit(1);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
经过修改,在某个任务进入account对象的deposit方法时,其他任务就无法进入deposit方法。
6.3 利用加锁同步
加锁同步可以显式地采用锁和状态来同步线程
上面介绍的synchronized关键字和同步语句都是隐式地对实例加上锁。Java也支持显式地加锁,以实现线程间的同步。一个锁是一个Lock接口的实例,定义了加锁和解锁的方法。
ReentrantLock是Lock的一个具体实现,用于创建相互排斥的锁。按如下方式修改程序中Account类的代码,便可显式加锁,实现线程同步:
private static class Account {
private static Lock lock = new ReentrantLock(); //创建一个锁对象
private int balance = 0;
public int getBalance() {
return balance;
}
public void deposit(int num) throws InterruptedException {
lock.lock(); //加锁
int newBalance = balance + num;
Thread.sleep(5);
balance = newBalance;
lock.unlock(); //解锁
}
}