并发学习心得

一、关于并发

  • 并发具有可以论证的确定性,但是实际上具有不可确定性。
  • 并发编程时,你应该具有的个性:多疑而自信
  • 程序神秘崩溃现象,很多是由并发缺陷引起的,有时候这种崩溃是温和的。但有时却是一种灾难。
  • 当你意识到明显正确的程序却展现了不正确的行为,那么试图考虑一下并发这个神秘的作祟者。
  • 使用并发解决问题的出发点:速度、设计可管理性
    二、任务与线程
    Runnable与Thread从使用上看并没有太大的区别,之前在面试的时候被面试官问到过Runnable与Thread有何区别,何时使用Runnable何时使用Thread。当时的回答是,一个是接口,一个是类因此在单继承和多实现上就有很大区别。至于何时选择使用两者之中的某者,出发点也是基于此区别的。不过后来对其了解慢慢加深,可以用“任务”和“线程”来更加准备的描述二者。所谓任务是一种客观的、除了执行给定操作之外什么也干不了的工作单元。而线程是具有生命周期的,是任务的载体,但它并不会实际去执行任务,只是触发、管理任务的执行。用最直接的话说:“任务必须附着到一个线程上才能被执行!”
         在此,通过一个线程异常的话题(顺便说一下线程的异常捕捉)来说明这一点:
    异常是无法跨线程进行传播的。所以主线程通常是不会被子线程的中异常所打断的,如下图所示:

CatchD8A0(12-15-(12-16-18-40-39)
这时,其实子线程中有未被处理的异常,它逃逸掉了!而通常遇到异常是需要让我们处理的,但直接加上try-catch是无效的。当然可以在run中添加try-catch捕捉线程内的异常,但有时候我们需要在其它地方统一处理这种异常,这时可以借助于Thread对象的setUncaughtExceptionHandler方法,

Catch2D65(12-15-(12-16-18-40-39)

但事实上,上面这种形式根本处理不了线程内的异常,Executor并没有调用传给线程的异常处理器。异常仍然逃逸了!!!,但是改为如下形式就可以了:
CatchB75E(12-15-(12-16-18-40-39)
当然,实际应用的时候应该把处理器单独拿出来,使代码的可读性更好。
比较两者的区别,前者是:通过一个Runnable任务创建了一个线程,然后为线程添加异常处理器,再将此线程交给ExecutorService执行。
    后者是:把一个Runnable任务传给一个Thread工厂,然后在工厂里利用这个任务创建一个线程,再为这个线程添加异常处理器,最后把工厂交给ExecutorService执行。
为什么前者无法捕捉异常,而后者可以呢?另外,类似下面这种方式也可以:
Catch5CC6(12-15-(12-16-18-40-39)
这是为什么呢?原因在于任务与线程的区别:
第一种靠ExecutorService不能捕获线程中逃逸出来的异常,因为它要接收的其实只是一个Runnable,即只是一个任务而已,而非一个线程,至于线程的创建工作由ExecutorService来接管,因此,尽管Thread实现了Runnable接口,它也会把这个Thread(t1)中的Runnable任务再附到它自己管理的一个Thread(t2)上去,因为ExecutorService会负责管理任务线程的创建,纵然你已经创建好了。然而ExecutorService创建的线程t2并没有设置异常处理器。不过,ExecutorService如果通过指定的线程工厂来创建的话,那么,因为工厂是由自己实现的,所以工厂中的线程也是由自己创建的,因此在工厂中创建线程,再为其添加异常处理器,就不会有问题了。
    而第二种没有通过Executor来执行任务,线程的创建是自己手动进行的,那线程中的异常自然也就能进行它的异常处理器了。
这从很大程度上说明了任务与线程在本质上是不同的,也很好的说明了二者之间的关系:“任务必须附着到线程上才能被执行”。
此外,关于Thread,它的有些方法值得注意:
    (1)、Thread.yield()方法是对线程调试器的一种建议,它在自我声明:“我已经执行完生命周期中最重要的部分了,此刻正是人其它任务执行一段时间的大好时机”。
    (2)、thread.Join()把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。
    (3)、thread.setPriority,设置线程优先级:在绝大多数时间里,所有线程都应该以默认的优先级运行,试图操纵线程优先级通常是一种错误的做法
    (4)、Thread.sleep,线程休眠,不过JDK5之后提供的新的style会更好一些: TimeUnit.MILLSECONDS.sleep(100);

三、线程同步------锁
synchronized关键字,synchronized实质上使用的是对象锁,而对象锁是在java的类实例中自动拥有的一种锁。重点是:“任何java对象,有且只有一把对象锁(或者称为对象监视器,但它可被多次获得)”。调用此对象的任意synchronized方法,那么整个对象都会被加锁,所以如果对象中有多个synchronized方法,那么一个Runnable任务一次只能调用其中的一个同步方法。因为这些方法都共享同一个锁,这要求域被设为private的,否则将导致synchronized失效。这个结论的验证可以通过一个神奇的小实验进行:

下面是一个测试,一个线程在无限循环中将初值为0的数不断的一次加2(注意到这是一个同步方法),理论上,得到的值总是一个偶数。而在主线程中来取这个值,只有当取到奇数时才中断程序,
那么它能取到奇数吗?
或者说如果能取到奇数,那取到的概率高不高呢?

    另外,对 return i;这样的原子操作有必要同步吗?

Catch5A25(12-15-(12-16-18-40-39)

上面的代码中,getValue方法中仅有一句:“return i”,显然它是原子性的,也没有任何线程安全问题,evenIncreament()方法体内的两个“i++”操作虽然不是原子的,但方法上已经有了synchronized进行同步。
其实上面的代码中出现奇数的概率非常高!就是因为evenIncreament方法执行到一半时,主线各过来读取了这个值。要避免它读取到不正确的值,可以在getValue方法上也加上“synchronized”关键字,此时就不存在出现奇数的问题了,那是因为对象锁只有一把,要调用getValue方法只能先取锁,如果能取到锁,则表明其它同步方法已经执行完毕并释放了锁。当然上述代码中的内级变量“i”在主线程和子线程之间还存在可视性的问题,它没有用volatile关键字限定。
要防止getValue取到一个未计算完的值,除了可以在getValue方法上使用synchronized进行同步之外,也可以将i变成AtomicInteger类型,即这样:

Catch7DE9(12-15-(12-16-18-40-39)

除了对象锁之外,类其实也有一把锁,它可以锁住非实例方法,如synchronized static可以为静态方法或代码块加锁。当然类锁其实也属于对象锁,只不过是属于Class对象而已。
误区:sychronized可以保证代码块的原子性。实际上,它只保证任务的序列化
Lock锁,这个和我们工程里舜哥写的MemLockUtils很类似,都是一种显式锁,比synchronized更灵活、控制粒度更细,但可读性比synchronized差。
四、原子性与可视性(Atomic和volatile都是不推荐在程序中经常使用的东西)
    原子类:Atomic,仅具原子性,不具有可视性
    易变性:volatile,仅具可视性,不具有原子性
原子性:读或写操作具有不可拆分性,即不会看到中间状态的值。基本类型变量的读写操作是具有原子性的,除了double和long,因为这两者占用了64位,中间产生了字拆分,因而有上下文切换的机会,所以不是原子的。但使用volatile进行修饰后就是原子的了。
可视性:一个任务做出的修改,即使在不中断的意义上讲是原子性的,但对其它任务也可能是不可视的,volatile确保了变量的可视性,如果多个任务在同时访问某个域,那么这个域就应该是volatile的,否则就应该被同步访问。由此可见,如果一个域完全由sychronized方法来防护,就没有必要是volatile的。它仅具可视性,不具原子性!

在《Java编程思想》一书中,很多地方提到,非常不建议在我们的应用程序中随便使用Atomic和volatile,而应该使用synchronized,因此下次再这么用的时候需要再想想是不是一定有这个必要!

谨慎使用volatile:
仅当类中只有一个可变的域时,选择volatile而非sychronized才算是相对较安全的。相反如果有多个volatile域,那么这些域之间极有可能存在依赖的情况。注意:当i依赖于它之前的值,或者i的值受到其它域的值的限制时,volatile将无法工作。优先使用sychronzied才是最安全的方式。
上面提到的两种情况下volatile无法工作的原因,在java编程思想上并没有指出来。不过原因也不难发现了:
(1) i依赖于它之前的值时,不要用volatile:原因出在i+上面,增量操作符+不是原子的。这个操作是先从堆内存中获得i值的副本放到缓存中,然后对副本值加1,最后再将副本值写回到堆内存的变量i中。从这个过程我们可以看到,从堆内存中获得i以及将值写回到i这两步都是同步的,但中间过程的就不能保证是同步的了。
(2) i受限于其它字段值时,不要用volatile:例如lower<upper,两个变量在读写时,虽然因为volatile而成为可见的了,但因为volatile的非原子性,setLower(5),setUpper(2)可以同时正确的被两个线程分别执行,然后结果却不正确了。即不能保证其中一个值被修改时,另一个值没有发生变化。
谨慎使用Atomic:
    Atomic类被设计用来构建java.util.concurrent中的类,因此只有在特殊的情况下才在自己的代码中使用他们。即使使用了也需要确保不存在其它可能出现的问题。另外,如果涉及多个Atomic对象,可能就应该考虑放弃使用Atomic,通常依赖于锁要更安全一点。(要么是synchronized关键字,要么是显示的Lock对象)
另外,Atomic类都有一个compareAndSet方法就是乐观锁机制,设值之前先比较之前的值是否被修改,这和我们工程中的数据库更新时用的乐观锁很像啊。JDK中的很多方法或者类的设计思想都是可以被借鉴的。
五、集合元素被多线程修改时的同步问题------源于对政均之前出现的问题的探讨

    出现问题的代码如下:

Catch8319(12-15-(12-16-18-40-39)
抛出来的异常是:java.util.ConcurrentModificationException at java.util.HashMap$HashIterator.nextEntry(Unknown Source)
查询其API,从API中可以看到List、Set等Collection的实现并没有同步化,如果在多线程应用程序中出现同时访问,而且出现修改操作的时候都要求外部操作同步化;调用Iterator操作获得的Iterator对象在多线程修改Set的时 候也自动失效,并抛出java.util.ConcurrentModificationException。这种实现机制是fail-fast,对外部 的修改并不能提供任何保证。
网上查找的关于Iterator的工作机制。Iterator是工作在一个独立的线程中,并且拥有一个 mutex锁(或者称之为令牌,只有拿到令牌的线程才可运行),就是说Iterator在工作的时候,是不允许被迭代的对象被改变的。Iterator被创建的时候,建立了一个内存索引表(单链表),这 个索引表指向原来的对象,当原来的对象数量改变的时候,这个索引表的内容没有同步改变,所以当索引指针往下移动的时候,便找不到要迭代的对象,于是产生错 误。List、Set等是动态的,可变对象数量的数据结构,但是Iterator则是单向不可变,只能顺序读取,不能逆序操作的数据结构,当 Iterator指向的原始数据发生变化时,Iterator自己就迷失了方向,所以它调用nextEntry时,抛出了Unknown Source的异常,如上面异常信息中的一样。
如何才能满足需求呢,需要再定义一个List,用来保存需要删除的对象:
List delList = new ArrayList(); 最后只需要调用集合的removeAll(Collection con)方法就可以了。不过,迭代器本身也提供了remove方法,可直接使用。这才是解决几个同步修改问题的最好方法。
六、Executor 之 ScheduledThreadPoolExecutor------core工程中的定时异步任务,定时刷新产品信息缓存。
自jdk1.5开始,Java开始提供ScheduledThreadPoolExecutor类来支持周期性任务的调度,在这之前,这些工作需要依靠Timer/TimerTask或者其它第三方工具来完成。但Timer有着不少缺陷,如Timer是单线程模式,调度多个周期性任务时,如果某个任务耗时较久就会影响其它任务的调度;如果某个任务出现异常而没有被catch则可能导致唯一的线程死掉而所有任务都不会再被调度。ScheduledThreadPoolExecutor解决了很多Timer存在的缺陷。
先来看看ScheduledThreadPoolExecutor的实现模型,它通过继承ThreadPoolExecutor来重用线程池的功能,里面做了几件事情:
为线程池设置了一个DelayedWorkQueue,该queue同时具有PriorityQueue(优先级大的元素会放到队首)和DelayQueue(如果队列里第一个元素的getDelay返回值大于0,则take调用会阻塞)的功能
将传入的任务封装成ScheduledFutureTask,这个类有两个特点,实现了java.lang.Comparable和java.util.concurrent.Delayed接口,也就是说里面有两个重要的方法:compareTo和getDelay。ScheduledFutureTask里面存储了该任务距离下次调度还需要的时间(使用的是基于System#nanoTime实现的相对时间,不会因为系统时间改变而改变,如距离下次执行还有10秒,不会因为将系统时间调前6秒而变成4秒后执行)。getDelay方法就是返回当前时间(运行getDelay的这个时刻)距离下次调用之间的时间差;compareTo用于比较两个任务的优先关系,距离下次调度间隔较短的优先级高。那么,当有任务丢进上面说到的DelayedWorkQueue时,因为它有DelayQueue(DelayQueue的内部使用PriorityQueue来实现的)的功能,所以新的任务会与队列中已经存在的任务进行排序,距离下次调度间隔短的任务排在前面,也就是说这个队列并不是先进先出的;另外,在调用DelayedWorkQueue的take方法的时候,如果没有元素,会阻塞,如果有元素而第一个元素的getDelay返回值大于0(前面说过已经排好序了,第一个元素的getDelay不会大于后面元素的getDelay返回值),也会一直阻塞。

最后,提醒自己在工作中不要陷入“承诺升级”的泥淖里。。

posted @ 2016-07-07 11:06  一人浅醉-  阅读(907)  评论(0编辑  收藏  举报