0%

《Java并发编程实战》阅读笔记

  1. (2.2.1 竞态条件)静态条件的两种场景类型:“先检查后执行(Check-Then-Act)”和“读取-修改-写入”,例如对于线程安全的Vector,当执行如下代码时:

    1
    2
    if(!vector.contains(element))
    vector.add(element);

    就存在“先检查后执行”的竞态条件。

  2. (2.4 用锁来保护状态)一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。换一个方式解读就是:如果存在的可变状态多于一个,并且要保证这些可变状态的修改是原子的,那么约定做法就是封装在一个类中,并且对该类进行操作时,都要先获取该类对象的内置锁。

  3. (2.5 活跃性与性能)如果只有一个变量需要原子操作,那么使用Atomic*是很有用的,但是如果多个变量需要原子性操作,那么就比较适合使用同步代码块,并且取消Atomic*的使用。

  4. (3.1.3 加锁与可见性)这一节有一个重要的结论:

    加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

    换句话说,加锁有两层用途:互斥排他和内存可见性。而后者是大家很容易忽视的。那内存可见性的一个应用场景就是:某一个线程写,其他线程读,这种情况下不存在并发竞争,而值存在内存可见性的问题。而这种情况下,大部分都是使用volatile,这也是为什么加锁的内存可见性的用途被忽视的另外一个原因。

  5. (3.2 发布与逸出)构造函数中this逸出的三种场景:

    • 在构造函数中new了内部匿名类,那么匿名类中就包含this指针
    • 在构造函数中启用了新的线程,并且构造后直接启动
    • 在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法)
  6. (3.3.1 Ad-hoc线程封闭)只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。

  7. (3.5.1 不正确的发布:正确的对象被破坏)本节中的AssertionError异常的例子的简单解释:1、JMM规范中并没有要求对象字段的初始化要happens before与另一个线程对该对象的可见性;2、if(n != n)并非一个原子性操作,而需要3步操作,因此会出现第一次读取的n值和第二次读取的n值不相同的情况。而如果将对象的字段改成volatile后就能避免AssertionError,这是因为JMM有规范要求:volatile sore要可见于其他线程。关于本节例子的两个很好的补充解释:知乎StackOverflow

    if(n != n)这行代码可以联想到一种并发编程易出错场景:某个函数里引用一个会被并发修改的字段,那么可能会出现在函数逻辑过程中发生变化的情况。这就引入了4.1.2 依赖状态的操作的内容,已经并发编程的一个基本思想:但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作(死循环加CAS?)。

  8. (4.3.3 当委托失效时)虽然AtomicInteger是线程安全的,但经过组合得到的类却不是。也就是说,如果一个类中的字段即使都是线程安全的,但是这个类也不一定是线程安全的。当这些字段之间存在某些不变性条件时,就会导致“先检查后执行”操作的出现。而这是前面所讲的竞态条件的一种常见场景。

  9. (4.3.5 示例:发布状态的车辆追踪器)注意本节的批注[1],很重要但是很容易忽视的一个并发编程错误场景!

  10. (5.1.3 隐藏迭代器)Synchronized*容器的toStringhashCodeequals函数会调用迭代器,因此在调用这些方法时,记得加锁,例如记录日志时或者将这些容器作为另一个容器的key或value时。这都是很容易忽视的!否则在迭代的过程中如果其他线程执行了并发增删操作,很有可能抛出ConcurrentModificationException。另外Synchronized*容器没有putIfAbsent之类的函数,如果要实现类似的检查并执行的操作,需要自己加锁!或者使用专用的并发容器,例如:ConcurrentMap

  11. (5.4 阻塞方法与中断方法)当捕获到InterruptedException时,不要私自吞掉该异常,要么继续向上传递该异常或者干脆不不捕获该异常,要么重新恢复中断:Thread.currentThread().interrupt();

  12. (5.5 同步工具类)闭锁(CountDownLatch):属于消耗性的只减不增的递减计数器,计时器为零时解锁;FutureTask:属于特殊的二元闭锁,运行中时阻塞,运行结束时解锁;信号量(Semaphore):属于池化的计数器,可赠可减,当池中计数器为0时阻塞,可以用任何容器加Semphore组合为有界容器。栅栏(CyclicBarrier):可以复用的递增计数器,初始化时,计数器为0,计数器未满时阻塞,计时器满时解锁,并且重置计数器。Exchanger:特殊的两方栅栏,任何一方都先发起交换请求,等待另一方响应后,交换达成,适用于读写线程进行无GC的缓冲交换(示例:baeldung)。

  13. (7.1.1 中断)对中断的正确理解是:它并不会真正地中断一个正在运行的线程,而是发出中断请求,然后由线程(自己决定)在下一个合适的时刻中断自己。通常,中断是实现取消的最合理方式。这句话的意思是,程序不需要自己顶一个类似volatile的cancelled的状态,并轮询检测该状态,已决定是否取消。如果采用中断的方式,那么可以调用:inerrupt(),如果线程处于阻塞中,会收到InterruptedException,如果处于非阻塞中,其interrupted状态会被标记。因此完整的程序框架类似于:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyThread extends Thread {
    public void run() {
    try {
    while(!Thread.currentThread().isInterrupted()) {
    //执行业务逻辑
    }
    }
    catch(InterruptedException e) {
    //线程退出
    }
    }

    public void cancel() {interrupt();}
    }
  14. (7.1.2 中断策略)这一节有时间再继续消化!

  15. (7.1.5 通过Future来实现取消)Future.get(long, TimeUnit)会返回4种异常,需要对这4种异常进行进行处理,特别是InterruptedException,不要私自吞掉!当一个Future已经完成后,再次调用cancel也不会有什么影响。

  16. (7.1.7 通过newTaskFor来封装非标准的取消)这一节主要讲了一件事:如果想在执行Future.cancel时执行额外的操作,那么:1、集成Callable接口,实现自己的Callable;2、继承ThreadPoolExecutor,重写newTaskFor函数,如果发现不是自己实现的Callable,那么调用super,否则返回一个自己实现的FutureTask,这个FutureTask的cancel函数先执行自己的一些操作,然后再调用super。

  17. (7.2 停止基于线程的服务)一个重要的概念:只有线程的所有者有权关闭线程,而线程池是其工作线程的所有者。

  18. (8.1.1 线程饥饿死锁)在单线程的Executor中,如果任务一将任务二提交到同一个Executor,那么会导致饥饿死锁!而在多线程的Executor中,如果这种现象比较多,也可能发生死锁。因此有一条规则:不要在同一个Executor中递归提交任务。当Executor A依赖Executor B时,A的有效线程数量(有可能)实际上隐式地依赖于B的线程数量。例如某逻辑线程池使用了包含10个连接的JDBC线程池。

  19. (8.2 设置线程池的大小)对于计算密集型任务,线程池的大小为CPU数量 + 1时,能实现最优利用率。而对于IO密集型线程池大小的评估公式为:CPU数量 * CPU利用率(大于等于0且小于等于1)* ( 1 + IO等待时间 / 计算时间)

  20. (12.3.5 无用代码的消除)在性能测试过程中,有一些技巧可以避免编译器对某些无用的代码进行消除,大致思路就是保证计算结果被使用,但是不要引入IO操作,而导致了性能偏差。下面的例子是一个很好的技巧:

    1
    2
    if(foo.x.hashCode() == System.nanoTime())
    System.out.print(" ");

    这段代码绝大数情况下不会成功,即使成功,也只是输出一个空字符。

  21. (14.2.2 过早唤醒)Object的内置对象锁存在一个条件队列,而唤醒这个条件队列的条件谓词可能不止一个,也就是说导致唤醒一个Object线程的条件不止是一个,因此当Object在wait后被唤醒时,仍然需要继续检测当时导致wait的同一个条件谓词,这也从另外一个角度说明了为什么要循环检测条件谓词(另外一个条件是:唤醒现象的发生

  22. (14.2.4 通知)每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。换句话说:wait和notify(notifyAll)一般都是在同一个函数内成对出现?另外由于同一个条件队列可以对应于多个条件谓词,因此尽量使用notifyAll而非notify(如果非得使用notify,需要满足两个条件:1、唯一条件谓词:条件谓词只有一个;2、单进单出:最多只能唤醒一个线程来执行,换句话说就是唤醒后,只有一个线程可以继续工作,剩下的线程会竞争失败而继续wait),否则会面临信号丢失的风险。

  23. (14.2.5 示例:阀门类)这一节的例子中,generation字段可以理解为批次,用一个形象的比喻就是这个Gate前可以等待一批又一批的线程,在await函数里,当线程被notifyAll后,当且仅当门是关闭状态并且当前的批次还是自己等待时的批次,才会再次wait(如果门已经开启,或者已经换了批次,那就证明gate肯定是开过,那我必须过去!)。

  24. (14.3 显示的Condition对象)与内置锁和条件队列一样,当使用显示的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须有Lock来保护,并且在检查条件谓词以及调用await和signal时,必须持有Lock对象。

  25. (15.3 原子变量类)原子标量类(AtomicInteg、AtomicLong、AtomicBoolean、AtomicReference)没有重新定义hashCode和equals方法,因此每一个实例都是不同的,因此也不适用于做散列容器的键值。