分享

并发总结1-线程、中断、锁(Lock)、协作

 碧海山城 2012-12-23
又是一些写了好久的存稿,有一些新的收获整理下赶紧发上来,这篇主要是关于线程的基本操作,中断,协作,以及有竞争情况下的同步。。。

线程基本概念

多线程java中本身就是无处不在,例如Servlet天生就是多线程的。

1.JVM,有一些线程管理自身的常规任务(垃圾回收、终结处理)

2.Servlet,线程池

3.Timer

4.RMI

5.SwingAWT

就算程序没有显示地创建任何线程,框架也会为你创建了一些线程!

1.1 并发的目的

1.更快的处理器vs更多的处理器并行运行

2.从某种角度上来看,将单处理器上顺序执行的程序,分成并发的程序,开销会更大,因为这会加上一些上下文切的代价。

但是在有阻塞(I/O)或者大计算的时候,如果没有并发,那么程序就会停止,有了并发,其他任务还可以同时进行。这里可以说提高运行速度,不过另一个角度来看,也是为了更加公平,合理分配资源。

1.2 进程并发

在发展的初期,计算机还没有操作系统时,自始自终只执行一个程序,这个程序直接访问机器的所有资源,每次只运行一个,浪费资源(特别是有阻塞资源的时候)

下面的原因促进了操作系统支持多程序同时执行的发展:

1.更好的利用资源,比如等待的

2.公平。多个程序通过时间片方式来共享计算机。

3.方便。写一些程序完成不同的事情,比写一个程序完成所有程序要更加容易。

并发最直接的方式就是进程,因为每个任务都作为进程在其自己的地址空间中执行他们是相互隔离的,他们是并发的理想状态,因为任务之间根本不可能相互干涉,有些人提倡将进程作为唯一合理的并发方式,但是,进程通常会有数量和开销的限制

1.3 线程并发

上面提到了促进多进程发展的一些原因,其实就是这些相同的原因,也促进了线程的发展,

它允许程序控制流(control flow)的多重分支同时存在于一个进程,共享进程内的资源,比如内存文件句柄,但是每个线程都有自己的程序计数器栈和本地变量,大多数现代操作系统把程序作为时序调度的基本单元,而不是进程。

(参考:复习功课:对进程、线程、应用程序域的理解

1.4 线程任务(Runnable


1、扩展java.lang.Thread类。 


此类中有个run()方法,Thread 的子类应该重写该方法。 

或者也可以接受Runnable类作为构造函数参数,直接启动!


2、实现java.lang.Runnable接口。 
void run() 
使用实现接口 Runnable 的对象创建一个线程时,启动该线程将导致在独立执行的线程中调用对象的 run 方法。 

A.启动线程

在线程的Thread对象上调用start()方法,而不是run()或者别的方法。 
在调用start()方法之后:发生了一系列复杂的事情,以前看到的图,蛮不错的:

 

 


B.线程休眠

Thread.sleep,让线程休眠指定的毫秒数,也可以是纳秒。当中断他的时候有可能抛出InterruptedException。

新版的API有了一个更加显式的版本,即TimeUnit。他是一个枚举,他除了sleep方法,还有比较有用的比如timedWait和timedJoin等方法。

TimeUnit.MILLISECONDS.sleep(100).

C.线程调度机制及优先级

setPriority(10)Thread.yield()

D.后台线程

所谓的后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且他不是不可或缺的一部分,因为,当所有非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。注意后台线程也不会执行finally方法,因为他是突然终止的

解惑 慎用守护线程Daemon

E.等待一个线程

可以在一个线程里面调用另一个线程t的join方法,表明等待t,直到t结束!

java5里面,java.util.concurrent类库中包含了CyclicBarrier(栅栏,大家都等待)这样的工具,可能比join更加合适!。

F.捕获线程的异常UncaughtExceptionHandler

由于线程的相对独立的本性,因此不能从线程中获取到为捕获的异常,

Thread.UncaughtExceptionHandler是java5中的新接口,允许在每个Thread对象上都附着一个异常处理器,uncaughtException(Thread t, Throwable e) 会在线程因未捕获的异常而濒临死亡的时候调用!

G.经验


1总是给自己的线程名字 
2获取当前线程的对象的方法是:Thread.currentThread() 
3、线程的调度是JVM的一部分,在一个CPU的机器上上,实际上一次只能运行一个线程。一次只有一个线程栈执行。JVM线程调度程序决定实际运 行哪个处于可运行状态的线程
众多可运行线程中的某一个会被选中做为当前线程。可运行线程被选择运行的顺序是没有保障的。 

1.5 多线程的风险

利用多线程,大部分情况下都能得到更好的性能,但是缺也有非常大的风险,但是如果并发状况下,要访问某些共享数据,比如:

1.集合的CRU操作、一些符复合操作(i++

2.某些关键资源的初始化,检查再运行(check-then-act

3.总的来看主要是:原子性、可见性、顺序(指令重排)

线程中断

从上面的线程状态图,可以看到,线程除了在Run的状态外,其实还有很多种情况下会wait,比如sleep方法的time wait或者join的普通wait,以及锁的block,所以在线程被hold住的时候我们能做什么?如果我们能够从等待的状态退出来,这就叫中断

正在处理的任务,让他停止,最简单的方式就是自己维持一个cancel的标志,类似这样:

private volatile boolean cancelled;

public void run(){

while(!cancelled){

//do sth

}

}

通过外部的某些接口来控制标志位,但是这种方式如果有阻塞的话就不行了,因此这时候根本不能走到判断的代码上。

2.1 可中断和不可中断

最常见的,我们在sleep的时候总会要处理一个Interrupted异常

Thread类包含interrupt方法,因此你可以终止被阻塞的任务,这个方法就设置线程的中断状态;如果这个线程已经阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将抛出InterruptedException

2.2 不可中断的处理

但是并不是所有的情况下,调用interrupt,线程都会中断,sleepjoin等会响应,但是比如IO read或者锁(synchronized)等情况就不会响应了

1.针对I/O,有一个麻烦,但是有效地方案,即通过外部方法直接关闭I/O.而不是靠中断去关闭。覆盖Interrupt方法或者FutureCancel方法

2.另外,nio类提供了可相应中断的类,被阻塞的通道会自动地响应中断。Nio的非阻塞I/O也不支持可中断i/o,但是可以通过关闭通道或者请求Selector上的唤醒来取消阻塞操作

3.对于锁的阻塞,不同线程去获取锁的时候会阻塞,一个线程内,调用两个syn的方法是不会阻塞的,因为锁是可重入的,当前线程已经获得了锁。另外,如果是用Lock去获取锁的话,那么它会可以响应中断

4.新版的API可以在Executor上调用shutdownNow,他将发送一个interrupt调用给他启动的所有线程。

5.如果中断单一的任务,可以使用Executorsubmit方法来启动任务,就可以持有Future对象,调用它的cancel方法。

2.3 检查中断和保留中断

还记得在前面说的,自己维护一个cancel标志位来处理中断,其实那是完全不必要的,每个线程都有一个与之关联的Boolean属性,表示线程的中断状态,初始为false,调用Thread.interrupt中断一个线程时,会出现两种情况:

1.如果那个线程在执行一个低级的可中断的阻塞方法,例如Thread.sleepjoinObject.wait,那么他将取消阻塞,重置标志位,并且抛出异常;

2.否则,interrupt只是设置线程的中断状态,被中断的线程,可以通过调用interrupted来轮询中断状态,interrupted方法还会同时清除中断状态确保不会通知你两次。如果只是单独的查询,可以使用Threaad.isInterrupted方法

保留中断状态

有些任务拒绝被中断,这使得它们是不可取消的。但是,即使是不可取消的任务也应该尝试保留中断状态,以防在不可取消的任务结束之后,调用栈上更高层的代码需要对中断进行处理(想象我们依赖于interrupted,但是经过别人处理之后,这个状态就被吞了,那是多么坑爹

下面 展示了一个方法,该方法等待一个阻塞队列,直到队列中出现一个可用项目,而不管它是否被中断。为了方便他人,它在结束后在一个 finally 块中恢复中断状态,以免剥夺中断请求的调用者的权利。(它不能在更早的时候恢复中断状态,因为那将导致无限循环 —— BlockingQueue.take() 将在入口处立即轮询中断状态,并且,如果发现中断状态集,就会抛出 InterruptedException。)

public Task getNextTask(BlockingQueue<Task> queue) {

    boolean interrupted = false;

    try {

        while (true) {

            try {

                return queue.take();

            } catch (InterruptedException e) {

                interrupted = true;

                // fall through and retry

            }

        }

    } finally {

        if (interrupted)

            Thread.currentThread().interrupt();

    }

}

锁(竞争)

并发之后,如果有共享资源,必然会有线程安全的问题,参考前面的【1.5 多线程的风险】

3.1 -synchronized

防止多个线程多字段访问的方法就是加锁,第一个访问这个资源的任务必须获得这项资源,使其他任务在解锁之前,无法访问他!

1.同步方法的锁

2.对象锁

public void test(){

int i=0;

//this表示当前的这个对象的锁

synchronized(this){

i++;

}

}

对象的内部锁和它的状态之间没有任何关系,即时获得了对象关联的锁也不能阻止其他线程访问这个对象,获得锁后唯一可以做的是阻止其他线程再次获得相同的锁

3.2 对象锁实现监视器

Java可以通过对象锁来实现互斥,允许多个线程在同一个共享数据上独立。所有对象都自动含有单一的锁(也称为是监视器),当在对象上调用任意synchronized方法的时候,对象都被加锁

 
 

当一个线程到了一个监视区域的开始处,他们就会被放置到该监视器的入口区,就像是走廊,如果没有其他线程在入口区中等待,也没有线程正持有该监视器,则这个线程就可以获得该监视器,并且继续执行监视区域中的代码。如果已经有其他线程在入口区中等待,则这个线程也必须在那里等待,直到有退出,并且会有一个线程获取监视器,这个是不确定的。

3.3 斥性/可见性/重排序

锁主要提供了两种特性:互斥性和可见性

1.互斥一次只允许一个线程持有某个特定的锁,因此可以使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据;

2.可见性和java内存模型有关,锁保证释放之前对共享数据作出的修改对于随后获得该锁的另一个线程是可见的

3.允许重排序

reordering inside synchronized block

如果syn内部的代码块没有依赖的话,是可以在syn内部重排序的,但是不会跑到外面。

另外synchronized也是可重进入

3.4 显示对象Lock

Synchronized确保了共享变量的原子性和可见性,它能够实现同步,但是,它也有一些限制:无法中断一个正在等候获得锁的线程,也无法通过投票得到锁


 

JDK1.5以后的Lock是锁的一个抽象,它允许把锁定的实现作为Java类,而不是语言的特性来实现。就为Lock的多种实现留下了空间,各种实现可以有不同的调度算法,性能特性或者锁定语义。

3.4.1 灵活性:锁范围的控制

Syn的范围机制使用起来更加简单,可以避免很多编程错误,只要将方法加上syn或者在代码块上涌syn{}括起来,就会完成加锁,解锁等所有功能;Lock能完成syn的功能,同时他更加灵活,可以让你在自己需要的范围以及顺序来请求和释放锁;当然灵活性也意味着更加复杂,我们需要显示的获得和释放锁:

Lock l = ...;

     l.lock();

     try {

         // access the resource protected by this lock

     } finally {

         l.unlock();//必须放在finally中,确保被释放

     }

3.4.2 灵活性可轮询和定时

灵活性不仅体现在范围控制的灵活性上,还体现在锁获得方式的灵活性。

tryLock方法,如果无法获得锁的话,返回false,这可以用来实现轮训的锁请求

还可以带上一个超时参数tryLock(long,TimeUnit),这可以用来实现定时的锁请求

响应中断的lockInterruptibly可中断的锁请求

3.4.3 ReentrantLock以及实现

ReentrantLock锁和syn块有相同的语义,它可以认为是Lock的最基本实现。他构造函数中的参数true/false,决定他是否是公平锁,公平锁会选择等待时间最长的线程执行,不过公平锁的性能是最差的,内部锁也是非公平锁,可以参考:ReentrantLockTest

ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,但是他添加了类似锁投票,定时锁等候和可中断锁等候的一些特性。不过它在使用的时候必须在finallyunLock,另外JVMsynchronized管理锁定请求和释放时,JVM在生成线程转存储时能够包括锁定信息,这样对调试很有用。Lock只是普通的类,JVM不知道具体哪个线程拥有Lock对象

所以在大多数情况下,使用synchronizeed更好,除非确实需要synchronized没有的特性,比如时间锁等候,可中断锁等候、无块结构锁、和锁投票等

 

3.4.4 性能

Java5ReentrantLock的性能优于SynJava6syn进行了改善,两者已经比较接近

3.4.5 公平非公平

ReentrantLock构造器的一个参数是boolean值,它允许选择一个公平(fair)锁,还是一个不公平锁(允许闯入)。公平锁使线程按照请求顺序依次获得锁,不公平锁并不是按顺序来的。

CPU的线程调度本来就是不公平的,JVM保证了所有线程都会得到他们所等候的锁,大多数情况下,确保所得公平性的成本非常高,比synchronized高的多,所以默认情况下,使用false的参数就可以了

在竞争激烈的情况下,非公平的锁性能较好,可能的原因之一是:因为“挂起一个线程和重新开始运行”是一个比较耗时的操作。假设A持有锁,B请求然后被挂起,之后A释放锁,如果此时有C请求锁,那么C就能直接获得,甚至在B被唤醒前C就已经释放了,这样B没有晚得到锁,C也更早的得到了锁

但是如果持有锁的时间比较长,或者请求锁的平均间隔较长,这时候可能公平锁也不错,因为闯入的优势:在线程被唤醒的过程中(还没有得到锁),被其他人得到,不容易出现。

ReentrantLock大部分的代码都是sync实现的,ReentrantLock只是简单的执行转发而已,

 

FairSyncNonFairSync都是ReentrantLock的静态内部类。Sync 是一个抽象类,而FairSyncNonFairSync则是具体类,分别对应了公平锁和非公平锁。

各种不同的Lock,功能更加丰富!

当然,还有另外一些锁允许并发访问,比如ReadWriteLock

ReentrantLock代码剖析

AbstractQueuedSynchronizer源码解析之ReentrantReadWriteLock

再谈重入锁--ReentrantLock

自己实现的java lock

JAVA LOCK代码浅析

《多处理器编程的艺术》读书笔记(7--- CLH队列锁

Lock详解

深入浅出多线程系列之十五:Reader /Write Locks (读写锁)

3.4.6 ReadWriteLock

维护了一对相关的锁,一个用于只读操作,另一个用于写入操作,只要没有writer,读取锁可以由多个reader线程同时保持。写入锁是独占地。

与互斥锁相比,读-写锁允许对共享数据进行更高级别的访问。虽然依次只有一个线程可以修改共享数据,但在许多情况下,任何数量的线程可以同时读取共享数据。

例如,某个数据填充后,不经常对其进行修改,因为不经常对其进行修改,所以这种情况,用读-写锁笔记哦合适。

3.4.7 自旋锁

针对锁的实现,除了要保证锁的性质(互斥、无死锁、)之外,都会面临一个问题,就是不能获取锁的时候怎么做?一种方案是继续尝试,这成为自旋锁。反复尝试的过程称为旋转或忙等待;另外一种方案是挂起线程,这种方案称为阻塞。

许多的实现都是将两种策略结合使用,先旋转一小段时间然后再阻塞。比如java里的AQS

协作(wait/notify

当使用多个线程来同时运行多个任务的时候,可以使用锁来同步两个任务的行为,这样保证不会相互干扰。但是,有些任务可能需要线程之间协作解决,这不再是彼此之间的干涉,而是彼此之间的协调

让这些线程协作,关键就是握手,这可以通过基础特性:互斥,可以确保只有一个任务可以响应某个信号,在互斥的基础上,还有一个途径,可以将自己挂起,直到外部条件发生变化!

这可以用Objectwaitnotify方法来安全的实现,另外,JAVA5还提供了具有awaitsignal方法的Condition对象

一些参考

探索 Java 同步机制

perfinsp

4.1 状态依赖

在并发程序中,有一些操作需要判断某个状态,如果不满足则阻塞,直到条件为真,再恢复。对于这些状态检查的代码,最原始的方式是这样:

Public v take(){

While(true){

Synchronized(this){

If(!isEmpty())

Return doTake();

}

Thread.sleep(1000);

}

}

上面的方式利用轮询加休眠的方式来实现阻塞,但是有个问题sleep容易睡过头,也就是说状态已经改变了,但是还在sleep里面;休眠时间越短,响应性越好;但是CPU消耗的更高;这种方式很不好,条件队列可以做这样的事情

4.2 条件队列:Waitnotify/notifyAll

条件队列可以让一组线程等待一些相关条件,直到条件为真;Java每个对象中都有内置锁,每个内置锁中也有内部等待队列,wait/notify/notifyAll构成了内部条件队列的API。使用这种方式,相对于轮询加休眠的方式,主要是多方面有了优化,CPU效率,上下文切换开销、响应性!理论上来说,用轮询+休眠的方式无法完成的事情,条件队列也无法完成

4.2.1 条件

如果条件不为真(缓存为空),那么take等待,直到另一个线程在缓存中置入一个对象;这时,需要把take对应的”线程“先放到一个队列里,等有数据了,再从队列里拿出来,就是wait方法,wait方法会将当前线程放入对应对象锁的阻塞队列(所以wait要放在syn块里,应该先持有该对象的锁),释放对象锁之后,阻塞当前线程,然后等待,直到特定时间的超时过后,或者被通知唤醒!

唤醒之后会返回运行前重新请求对象锁,一个从wait方法唤醒的线程,在重新请求锁的过程中,没有任何特殊的优先级,她像在任何其他尝试进入synchronized快的线程一样去争夺锁

waitsleep有两个显著的不同:

1Wait期间锁是释放的

2.可以通过notify/notifyAll或者时间到期,让wait恢复执行

4.2.2 Wait/notify等待队列

前面已经说过,获得锁有一个等待区域,每一个同步锁lock下面都挂了几个线程队列,包括就绪(Ready)队列,等待(Waiting)队列(看3.2的图等。当线程A因为得不到同步锁lock,从而进入的是lock.ReadyQueue(就绪队列),一旦同步锁不被占用,JVM将自动运行就绪队列中的线程而不需要任何notify()的操作。但是当线程Await()了,那么将进入lock.WaitingQuene(等待队列),同时占据的同步锁也会放弃。而此时如果同步锁不唤醒等待队列中的进程(lock.notify()),这些进程将永远不会得到运行的机会。

为什么,waitnotifyObject的方法,而不是线程的?

因为锁,队列都是在对象上的(每个对象都有个监视器),因此,waitnotify都是object的方法,方法会操作的是这个监视器的队列(就绪队列/等待队列),所以不是线程的方法

4.3 协作问题--过早的唤醒

一个锁的等待队列中可能会有多个等待线程,他们可能是因为相同或者不同的条件在等待,调用notifyAll/notify的时候会有线程A被唤醒,不过也许这个通知时针对另一个线程B发出的,所以你过早的被唤醒了,并且再也回不去了!

因此,当你从wait中唤醒之后,都必须再次测试条件,如果不为真,就继续等待,因此你永远应该在while内部调用wait

Synchronized(lock){

While(!condition)

Lock.Wait();

}

4.4 生成者与消费者

厨师和服务员是经典的消费者,生产者的例子,服务员等待厨师做好食物,厨师准备好以后会通知服务员,服务员上菜以后继续等待,这是一个任务协作的过程,可以用wait/notify实现。

1.1.1 Wait/notify实现

Waitnotify依赖于一个锁可以实现生产者/消费者模型,就是使用的时候要注意一定要放在一个syn块里

以前为公司出的一道面试题,有点偏,有兴趣的可以试试

1.1.2 BlockingQueue

Waitnotify以一种非常低级的方式解决了任务互操作的问题,即每次操作的时候都握手。不过可以使用更高级别的操作,同步队列来解决线程协作的问题。

BlockingQueue有大量标准的实现,比如LinkedBlockingQueue,一个无界队列,ArrayBlockingQueue,她有固定尺寸,超过了会阻塞!




    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多