原创

【每天十分钟JAVA快速入门】(十六)并发

并发

并发就是系统可以同时处理多个任务的能力,例如我们在电脑上可以一边浏览网页,一边听音乐,同时还在下载资料,这种能力就是并发。提到并发,计算机世界中总是离不开所谓的进程、线程、协程等概念。

进程
一天,老妈从超市买菜回来,带了一大包没有剥壳的毛豆,但是她不习惯连壳水煮的做法, 那么首要的任务就是把这一大包毛豆的壳剥掉。为了执行这项任务,她开始分配资源:准备一个垃圾桶装剥掉的壳,准备一个盘子装剥好的毛豆,准备一张小桌子放盘子,准备一个小椅子坐着干活舒服点。资源分配完毕,现在坐下,开始干活,一个剥毛豆进程启动了。
可能是由于今天超市大减价,这一大包毛豆实在是有点多,老妈这单个剥毛豆的进程跑了半天也没完成。于是她有点不耐烦了,正好看到我和老爸一个在玩游戏一个在刷手机,不由得怒从心头起,大喝一声:“你们两个别闲着,来帮忙一起剥毛豆!” 说罢就起身给我们俩每人准备一套生产资料(垃圾桶、盘子、小桌子、小椅子)后下达指令:“开工!”,没办法,领导发话不得不从啊,于是又有两个剥毛豆进程启动了。
这下有三个剥毛豆的进程在跑了,效率大大提升,很快毛豆全部剥完,任务顺利完成,领导很满意,我和老爸又可以开心的玩游戏刷手机去了,皆大欢喜!

线程
一个月后的某一天,超市又打折了,于是又有一大包毛豆摆在了我和老爸面前。就在我们俩大眼瞪小眼面面相觑的时候,老妈发话了:“上次我们剥毛豆的办法很好,就是每人准备一套生产资料太麻烦了,代价有点大。这次我们优化一下,生产资料一份就可以了,我们三围在一起就能开始了。对了你们俩不要椅子的话也可以站着。” “要要要!切克闹!”我们俩虎躯一震,赶紧各自拉了一把椅子坐下。“开工”老妈一声令下,剥毛豆进程启动,同时我们三个线程开始运行。
围在一起干活关键还是得看配合,一开始有些磕磕碰碰,经过一段时间的磨合,我们仨都熟练了,配合默契,圆满完成任务,皆大欢喜!

进程和线程的关系
通过上面的例子我们使用专业点的语言来总结一下进程和线程的关系:
进程是操作系统分配系统资源(CPU时间片、内存等资源)的基本单位,每个进程都有自己的独立的资源,一个进程运行时至少有一个线程在运行。
线程是操作系统(CPU)调度执行的基本单元,是进程中的一个执行单元,相同进程下的线程共享进程资源,一般在执行过程中,需要协作同步。

线程状态
Java中的线程线程状态有6种:
NEW(新创建)
当new一个新线程时,该线程还没有开始运行,此时状态为NEW。
RUNNABLE(可运行)
调用start方法后线程处于RUNNABLE状态,此时线程可能在运行也可能没有在运行,这取决于操作系统的调度。
BLOCKED(被阻塞)
当一个线程试图获取一个内部对象锁,而该锁被其他线程持有,则该线程进入BLOCKED状态。
WAITING(等待中)
当一个线程等待另一个线程通知调度器某个条件时,它会进入WAITING状态。
TIMED_WAITING (计时等待中)
有一些带超时参数的方法调用后会使线程进入TIMED_WAITING 状态。这一状态会一直保持到超时时间到达或者接收到了适当的通知。
TERMINATED(被终止)
run方法正常退出,或者运行过程中有没有被捕获的异常发生而导致run方法异常退出。

线程优先级
每个线程都有一个优先级,当线程调度器选择新线程时 ,它会首先选择优先级较高的线程。优先级必须在Thread.MIN_PRIORITY与Thread.MAX_PRIORITY
之间 ,一般使用Thread.NORM_PRIORITY优先级。一般情况下不需要去手动设置优先级,优先级设置不当可能导致低优先级的线程完全没机会被调度到。

守护线程
守护线程的作用就是为其他线程提供服务,Java中最典型的守护线程就是GC(垃圾回收器),如果虚拟机中只有守护线程时,虚拟机就会退出了,已经没有继续运行的意义了,比如只有垃圾回收器但是没有垃圾制造者了,就没有什么意义了。可以通过调用setDaemon(true)将线程设置为守护线程,但必须在启动线程之前调用。

同步
上面提到线程共享进程中的资源,在绝大多数情况下,多线程应用都会遇到两个或以上的线程需要对共享数据进行读写的操作。这种情况会发生数据的混乱,试想如果一个线程读取了共享数据,在重新写入之前另一个线程修改了这个数据,此时当前的线程并不知情,仍然认为数据还是之前的状态,这样显然会带来问题。为了避免发生这样的问题,进程需要同步存取。

我们来实现一下上面的多线程剥毛豆,假设有n个人参加剥毛豆的任务,每个人会平均分到一些毛豆,每隔一段时间将自己剥好的毛豆放在统一的地方,直到自己分配到的毛豆剥完为止,由于我们现在的电脑性能已经很强大了,太简单的任务发现不了问题,于是我们将参与者设置为1000,每人分到的毛豆数量也设为1000,这样任务的毛豆总数量应该是1000000。我们期待的结果是不管每个人每次剥好了多少,手中还剩余多少,总数应该不变一直都是1000000:
public class Beans {
//已剥好的毛豆总数
private int totalCompleted = 0;
//每个参与剥毛豆的人员手中剩余的毛豆数量
private int[] inventories;

/**
* @param workers 参与剥毛豆的人员数量
* @param quota 每个人分配到的毛豆数量
*/
public Beans(int workers,int quota) {
inventories = new int[workers];
Arrays.fill(inventories,quota);
}

/**
* 增加剥好的毛豆总数
* 减去对应参与者手中剩余的毛豆数量
* @param workerId
* @param subCompleted
*/
public void peel(int workerId, int subCompleted){
if(subCompleted == 0 || subCompleted > inventories[workerId]){
if(inventories[workerId] == 0){
System.out.printf("Total %d \n",getTotal());
}
}else{
System.out.print(Thread.currentThread());
inventories[workerId] -= subCompleted;
System.out.printf("worker %d complete %d ",workerId,subCompleted);
totalCompleted += subCompleted;
System.out.printf("Total %d \n",getTotal());
}
}

/**
* 获取任务毛豆总数量
* @return
*/
public int getTotal(){
int totalInventory = 0;
for(int inventory : inventories){
totalInventory += inventory;
}
return totalInventory + totalCompleted;
}

public static void main(String[] args) {
Beans beans = new Beans(1000, 1000);
System.out.println("Task begin with total : " + beans.getTotal());
for (int i = 0; i < 1000; i++) {
int workId = i;
Runnable r = () -> {
while (true) {
int subCompleted = (int) (10 * Math.random());
beans.peel(workId, subCompleted);
try {
Thread.sleep((int) (10 * Math.random()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t = new Thread(r);
t.start();
}
}
}
结果事与愿违,程序一运行打印出任务毛豆的总数量:
Task begin with total : 1000000
之后打印的Total会随机的出现错误,并不再是1000000了:
Thread[Thread-20,5,main]worker 20 complete 2 Total 999978
Thread[Thread-44,5,main]worker 44 complete 3 Total 999978
Thread[Thread-256,5,main]worker 256 complete 1 Total 999978
Thread[Thread-6,5,main]worker 6 complete 2 Total 999976
Thread[Thread-27,5,main]worker 27 complete 1 Total 999976
Thread[Thread-31,5,main]worker 31 complete 9 Total 999976
...
直到所有人手中的毛豆都剥完,也就是inventories中的所有值都为0,程序会很可能会最终打印出一个不是1000000的结果,例如:
Total 999993
Total 999993
Total 999993
....

少几个毛豆这算不了什么,但是如果这个数字是我们的银行卡余额呢?那一定是接收不了的,一分钱也不能错!
那么问题究竟出在哪呢?就是大家剥好了毛豆都扔到一个地方,每个人都依据自己的数据去更新了totalCompleted这个变量 :
totalCompleted += subCompleted;
问题在于这个操作并不是原子操作(一步就完成了),它分为三步:
1. 加载totalCompleted当前的值
2. 增加subCompleted
3. 将结果写回totalCompleted
假设有一个人A做完了1和2,还没来得及完成3被叫停了,这时候有另一个人B完成了3,紧接着A也完成了3,此时A就会将B修改的totalCompleted值抹去换成自己的,错误就这样发生了。

对象锁
如何避免代码在并发的情况下出现上面的错误呢?Java提供了锁机制来解决这个问题,例如可以使用ReentrantLock,我们将上面的代码改成如下结构:
public class Beans {
ReentrantLock lock = new ReentrantLock();
...
public void peel(int workerId, int subCompleted){
lock.lock();
try {
...
}finally {
lock.unlock();
}
}
...
}
这样就可以确保任何时刻只有一个线程进入临界区。一旦一个线程获取了锁对象, 其他任何线程调用 lock时,就会被阻塞,直到第一个线程释放锁对象。一定要将unlock写在finally中以确保即使获取锁的线程发生异常也可以将锁释放,以免发生永久性的阻塞。
再次运行剥毛豆程序,这下Total总是打印1000000了。

条件对象
在剥毛豆的过程中有人快有人慢,有人很快就把手中的活干完了,这时候再轮到他的时候其实他就在那里闲着没事做,有点浪费资源哦。能者多劳嘛,我们试着将忙不过来的人手上的活移交一部分到他们的手上。
增加一个方法,如果当前这个人的手上剩余的毛豆数量大于500,他可以给一个手上没活的人转50个毛豆:
public void assign(int workerId){
if(inventories[workerId] > 500){
for(int i = 0; i < 1000; i++){
if(inventories[i] == 0){
inventories[workerId] -= 50;
inventories[toWorker] += 50;
}
}
}
}
Java提供了一种称之为条件(Condition)的解决方案来处理这种线程进入临界区后需要满足某些条件才执行的情况。可以给一个对象锁添加一个或多个条件对象,在不满足条件时调用该条件对象的await方法进入该条件的等待集,然后放弃锁。当该条件对象的signalAll被调用时,该条件等待集中的线程将被激活。signalAll 方法只是通知正在等待的线程,不保证条件一定满足,需要线程自己再次检查。

现在我们来改造一下peel方法,在手上没活的时候也能做点什么。做点什么呢?事实上还是什么也做不了,不过他可以等着别人给他分一些毛豆,分到额外的毛豆后可以继续工作。
private ReentrantLock lock = new ReentrantLock();
private Condition inventoryNotEmpty = lock.newCondition();
public void peel(int workerId, int subCompleted){
lock.lock();
try {
...
while(inventories[workerId] == 0){
System.out.printf("Worker %d is waiting for new beans. Total %d \n", workerId,getTotal());
inventoryNotEmpty.await();
}
...
this.assign(workerId);
inventoryNotEmpty.signalAll();
}finally{
lock.unlock();
}
}
实际运用中正确地使用Condition还是比较困难的,非常有挑战性。

synchronized
在大多数情况下我们并不需要像上面那样使用到Lock/Condition。Java中的每一个对象都有一个内部锁,使用synchronized关键字可以更简洁地完成同步。
public synchronized void peel(int workerId, int subCompleted){
...
while(inventories[workerId] == 0){
System.out.printf("Worker %d is waiting for new beans. Total %d \n", workerId,getTotal());
wait();
}
...
this.assign(workerId);
notifyAll();
}
并发其实非常复杂很难使用很短的篇幅进行阐述并在短时间内掌握,作为入门篇,我们仅介绍很少的一部分基本概念,大致了解一下,有机会再深入研究。

正文到此结束