,在程序设计语言中,线程对程序员可以说是一种又爱又恨的矛盾,一方面可以大大的简化模型,帮助程序员编写出功能强大的代码;另一方面又可能因为考虑不周全使得我们的程序出现各种大大小小的很难在开发环境中复现的BUG。
在这里笔者根据《Java核心技术》一书以及一些博文来和大家分享一下对Java多线程的理解和体会
进程与线程
在学习之前我们有必要了解什么是进程和线程。
进程
所谓的进程就是程序或者任务执行的过程,进程持有资源(内存,文件)和线程。例如我们所用的QQ,Eclipse等等都可以说是进程。在这里笔者特别提醒大家,特别要注意进程是程序或者任务的执行过程,只有当Eclipse等程序被启动了才能被称之为进程。因此进程是一个动态的概念。
线程
我们说进程中包括资源和线程,所以进程是资源和线程的载体,脱离了线程讨论进程是没有意义的。那么什么是线程。一个程序往往可以执行多个任务,通常每个任务被称为一个线程。例如,Eclipse中有源代码编辑器,可以做语法校验,可以后台编译我们的源代码。
所以综上所述,我们可以总结一下几点:
- 线程是系统中最小的执行单元
- 同一个线程中可以包含多个线程
- 线程共享进程中的资源
线程的交互
在这里读者们可能已经很熟悉现代操作系统中的多任务:在同一时刻运行多个程序的能力。多个线程通过通信才能正常的工作。这种通信我们称为线程的交互。交互的方式可以分为互斥与同步两种。例如,我们把一个学校比作一个进程,每个学生都可以看作一个线程。那么其中要相互合作完成一些项目或者学习任务的行为可以理解为同步;学校中有图书馆,当一位同学从图书馆把《Java核心技术》借走了,并且图书馆中只有一本《Java核心技术》,那么其同学就不能借到这本书,只有等待之前的同学把书换回去。这就可以理解为互斥。
线程的创建
现在我们说一下线程的创建。线程的创建常用的有两种
- 继承java.lang.Thread
在这个类中有一个run()方法。如果该线程是由Runnable对象构造的,则调用该Runnable对象中的run()方法;否则Thread子类中应该重写该方法。对此中线程的实例化则直接用new即可。
- 实现java.lang.Runnable接口
同样,该接口中只有一个run()方法。
使用实现接口Runnable的对象创建一个线程时,启动该线程将会调用run()方法。实例化这种线程则是用Thread的构造方法。如下:
Thread(Runnable target)
Thread(Runnable target, String name)
Thread(ThreadGroup group, Runnable target)
Thread(ThreadGroup group, Runnable target, String name)
Thread(ThreadGroup group, Runnable target, String name, long stackSize)`
线程的启动
启动一个线程,则只需要调用Thread对象中的start()方法即可。其实一个线程调用了start()方法后,并不是说该线程就处于运行状态了,在这里笔者对线程的状态不做详解,在之后的文章中会做详细的说明
线程的创建实例
下面同过一个小例子来说明一下线程的创建:
package org.joea.java;
public class ThreadDemo extends Thread {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName()+"是主线程!");
Thread threadDemo = new ThreadDemo();
threadDemo.setName("Thread");// 为线程设置名字
// 启动线程
threadDemo.start();
// 通过Runnable接口对象创建线程并设置线程名称
Thread threadRunnable = new Thread(new ThreadRunnableDemo(), "Runnable");
// 启动线程
threadRunnable.start();
}
// 重写run()方法
public void run() {
System.out.println(getName() + "是一个Thread的扩展线程!");
int count = 0;
boolean keyRuning = true;
while (keyRuning) {
System.out.println(getName() + "运行了" + (++count) + "次");
if (count == 10) {
keyRuning = false;
}
if (count <= 10) {
try {// 让线程休眠1秒
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
System.out.println(getName() + "结束了!");
}
}
class ThreadRunnableDemo implements Runnable {
@Override
public void run() {
// TODO Auto-generated method stub
System.out.println(Thread.currentThread().getName()
+ "是一个实现Runnable接口的线程!");
int count = 0;
boolean keyRuning = true;
while (keyRuning) {
System.out.println(Thread.currentThread().getName() + "运行了"
+ (++count) + "次");
if (count == 10) {
keyRuning = false;
}
if (count <= 10) {
try {// 让线程休眠1秒
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
System.out.println(Thread.currentThread().getName() + "结束了!");
}
}
以上代码演示了Java中创建线程的两种方式,从上面的代码中我们可一得到如下几条:
- 线程都可以设置名字,也可以获取线程的名字。线程的名字既可以是程序员自己决定的,也可以是JVM自己赋予的。
- 以上代码中的线程只能保证:每个线程都能启动并且运行至结束。但是对于任何一组(两个或以上)线程,都不能保证其运行次序,运行时间也无法保证(这里涉及抢占式调度原则,之后的文章会又介绍)。
- 通常当线程中的run()方法结束,意味着线程的结束
同步
在大多数实际的多线程的应用中,两个或者两个以上的线程需要共享同一数据的存取。试想一下如果两个进程都调用了同一个修改数据库中数据的方法,可能会导致修改后的数据出现意想之外的错误。
例如:模拟有一个银行。里面有n个账户,每一账户有一个线程,每一笔交易中,会从线程所在的账户转到另一个账户中随机数目的钱。
BankTest.java
package org.javathread.joea;
public class BankTest {
public static final int NACCOUNTS=100;
public static final double INITIAL_BALABCE=100;
public static void main(String[] args) {
Bank b=new Bank(NACCOUNTS, INITIAL_BALABCE);
int i;
for(i=0;i<NACCOUNTS;i++){
TransferRunnable r=new TransferRunnable(b, i, INITIAL_BALABCE);
Thread t=new Thread(r);
t.start();
}
}
}
Bank.java
package org.javathread.joea;
public class Bank {
private final double[] accounts;
/**
*
* @param n
* 用户数目
* @param initialBalance
* 用户初始存款
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++) {
accounts[i] = initialBalance;
}
}
/**
* 从from账户转钱到to账户
* @param from 转出账户
* @param to 转入账户
* @param amount 转钱数目
*/
public void transfer(int from, int to, double amount) {
if (accounts[from] < amount) {
return;
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
}
/**
* 重新计算银行中数据的总值
* @return 钱的总数
*/
private double getTotalBalance() {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
/**
* 得到用户数量
* @return 用户数量
*/
public int size() {
return accounts.length;
}
}
TransferRunnable.java
package org.javathread.joea;
/**
* 模拟用户转账的线程
*
* @author Joea
*
*/
public class TransferRunnable implements Runnable {
private Bank bank;
private int fromAccount;
private double maxAmount;
private final int DELAY = 10;
public TransferRunnable(Bank b, int from, double max) {
bank = b;
fromAccount = from;
maxAmount = max;
}
@Override
public void run() {
// TODO Auto-generated method stub
try {
while (true) {
int toAccount = (int) (bank.size() * Math.random());
double amount = maxAmount * Math.random();
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep(DELAY);
}
} catch (InterruptedException e) {
// TODO: handle exception
}
}
}
上面的代码是通过调用Bank类中的transfer方法,使用户从一个账户转移一定数目的钱款到另外一个账户。然后通过TransferRunnable类中的run方法不断的从银行中从某一个账户中随机取出一定数目的钱,并且选择一个随机的目标账户进行转账。运行的结果如下:
52.39 from 31 to 90Totle Balance : 10000.00
Thread[Thread-55,5,main]
86.58 from 55 to 15Totle Balance : 10000.00
Thread[Thread-7,5,main]
44.79 from 7 to 77Totle Balance : 10000.00
Thread[Thread-30,5,main]
14.38 from 30 to 22Totle Balance : 10000.00
Thread[Thread-53,5,main]
.
.
.
Thread[Thread-78,5,main]
34.40 from 78 to 61Totle Balance : 9912.33
Thread[Thread-1,5,main]
42.24 from 1 to 46Totle Balance : 9912.33
Thread[Thread-70,5,main]
27.56 from 70 to 69Totle Balance : 9912.33
这里我们发现银行中钱的总数发生的轻微的变动。查看我们的代码,我们会发现,我们虽然对银行中的账户进行了相互的转账,但是我们并没有改变银行中钱的总数目。但是却发生了我们意想不到的错误。这是为什么呢。
这里我们分析一下我们程序中的转账流程:
- 获取目标账户account[to]
- 增加amount数目的钱
- 将结果从新写入account[to]
现在我们假定某一个线程K获得了account[i],并且刚好执行完步骤2,然后它被剥夺了运行的权利。然后另一个线程H被唤醒并且修改了account[i]中的数值,然后K线程被唤醒并且完成了第三步。所以导致了总的数目不再正确。
锁对象和条件对象
锁对象
Java中存在两种机制来防止代码受并发访问的影响。这里我们先介绍锁对象和条件对象。
java.util.concurrent框架中为我们提供了Lock接口并且引入了一个继承Lock接口的一个类——ReentrantLock类。
这里让我们先使用一个锁来保护Bank中的transfer方法:
public class Bank {
private Lock bankLock = new ReentrantLock();
.
.
.
public void transfer(int from, int to, double amount) {
if (accounts[from] < amount) {
return;
}
bankLock.lock();
try {
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
} finally {
bankLock.unlock();
}
}
.
.
.
}
这是我们再运行代码的话,就会发现银行中的钱的总数不会发生改变。这是因为一旦一个线程封锁了锁对象,那么其他的任何线程都无法通过lock语句,都会进入阻塞状态,直到第一个线程释放锁对象。所以当一个线程调用transfer时,即使在执行结束前被剥夺的运行的权利,此时第二个调用transfer的线程也不能获得锁对象。它必须等待第一个线程释放锁对象。
条件对象
再来看我们的transfer代码:
public void transfer(int from, int to, double amount) {
if (accounts[from] < amount) {
return;
}
....
}
当我们的转出账户中的钱少于amount是我们没有采取任何的操作。这里我们对程序进行细化。我们避免选择没有足够的资金的账户作为转出账户。并且确保没有其他的线程在本线程检测余额与转账操作之间修改余额。所以通过锁对象来保护检测余额与转账操作:
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
while(accounts[from]<amount){
....
}
....
} finally {
bankLock.unlock();
}
}
当某个线程中发现余额不足的时候,我们想要该线程等待直到另一个线程为该账户注入足够的资金。但是这一线程刚刚获取了对bankLock的排他访问,因此别的线程没有对该账户进行访问的权限。这就是引入条件对象的原因。
一个锁对象可能会拥有多个条件对象。我们可以用newCondition方法来获取一个Condition对象。当该对象发现不能满足线程的运行条件的时候,就会调用await()方法使当前线程进入阻塞状态,并且放弃了锁。比如:当transfer方法发现账户中余额不足的时候就会调用await()方法。等待另一个线程对该账户进行增加余额的操作。一旦一个线程调用了await方法,它将进入该条件的等待集中。当锁可用的时候,该线程并不能马上解除阻塞状态,直到另外一个线程调用同条件上的signalAll方法为止。此时线程会再次检测条件。
最后我们的Bank.java中的代码如下:
package org.javathread.joea;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Bank {
private Lock bankLock;
private Condition sufficient;
private final double[] accounts;
/**
*
* @param n
* 用户数目
* @param initialBalance
* 用户初始存款
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++) {
accounts[i] = initialBalance;
}
bankLock = new ReentrantLock();
sufficient = bankLock.newCondition();// 获得一个与该锁相关的条件F
}
/**
* 从from账户转钱到to账户
*
* @param from
* 转出账户
* @param to
* 转入账户
* @param amount
* 转钱数目
* @throws InterruptedException
*/
public void transfer(int from, int to, double amount) throws InterruptedException {
bankLock.lock();
try {
while (accounts[from] < amount) {
sufficient.await();//余额不足,进入阻塞状态
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
sufficient.signalAll();//唤醒条件等待下的所有线程
} finally {
bankLock.unlock();
}
}
/**
* 重新计算银行中数据的总值
*
* @return 钱的总数
*/
private double getTotalBalance() {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
/**
* 得到用户数量
*
* @return 用户数量
*/
public int size() {
return accounts.length;
}
}
下面我们来总结一下锁和条件的关键之处:
- 锁对象可以保证任何时刻只能有一个线程执行被保护的代码
- 锁可以拥有一个或者多个相关的条件对象
- 锁可以管理那些试图进入被保护代码的线程
- 每个条件对象管理那些已经进入被保护的代码但是还不能运行的线程
synchronized关键字
虽然Lock和Condition接口为程序员提供了高度的锁定控制。但是,事实上从Java 1.0开始每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明。那么对象的锁将保护整个方法。
内部锁只有一个相关条件。wait方法添加一个线程到等待集。notify和notifyAll方法接触等待线程的阻塞状态。
用synchronized修饰的Bank方法如下:
package org.javathread.joea;
public class Bank {
private final double[] accounts;
/**
*
* @param n
* 用户数目
* @param initialBalance
* 用户初始存款
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++) {
accounts[i] = initialBalance;
}
// bankLock = new ReentrantLock();
//
// sufficient = bankLock.newCondition();// 获得一个与该锁相关的条件F
}
/**
* 从from账户转钱到to账户
*
* @param from
* 转出账户
* @param to
* 转入账户
* @param amount
* 转钱数目
* @throws InterruptedException
*/
public synchronized void transfer(int from, int to, double amount) throws InterruptedException {
while (accounts[from] < amount) {
wait();//余额不足,进入阻塞状态
}
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
//sufficient.signalAll();//唤醒条件等待下的所有线程
notifyAll();//唤醒条件等待下的所有线程
}
/**
* 重新计算银行中数据的总值
*
* @return 钱的总数
*/
private synchronized double getTotalBalance() {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
/**
* 得到用户数量
*
* @return 用户数量
*/
public int size() {
return accounts.length;
}
}
可以看出用synchronized关键字来编写代码要简洁的多。当然要理解这种代码,你就必须要了解Lock对象和Condition对象。但是这种内部锁还是存在一些局限性的。如:当一个线程试图获得锁的时候不能设置超时条件;每个锁的条件单一,可能是不够的;不能中断一个正在试图获得锁的线程。
关于线程还有很多的知识,深知自己只学习了线程的一些皮毛。之后的文章中会继续的学习和分享对线程和同步学习的心得。
以上。