多线程-java并发编程实战笔记

时间:2022-12-04 18:03:25

线程安全性

  1. 编写线程安全的代码实质上就是管理对状态的访问,而且通常都是共享的可变的状态。
  2. 一个对象的状态就是他的数据,存储在状态变量中,比如实例域或静态域。所谓共享是指一个对象可以被多个线程访问;所谓可变是指变量 的值在其生命周期之内可以改变。
  3. 无论何时只要多于一个线程访问给定的状态变量,而且其中的某个线程会写入该变量,此时必须使用同步来协调该线程对该变量的访问。java中首要 的同步机制是synchronized的关键字,它提供了独占锁。除此之外,术语“同步”还包括volatile关键字,显示锁和原子变量的使用。
  4. 在没有正确使用同步的情况下,如果多个线程访问同一个变量,可以使用如下三种方式修复:
    • 不要跨线程共享变量
    • 使状态变量为不可变的
    • 在任何访问状态变量的时候同步
  5. 无状态对象永远是线程安全的,多数servlet都可以实现为无状态的,这一事实也极大的降低了servlet线程安全的负担。

原子性

  1. 自增操作是三个离散操作的简写形式:获得当前值;加1;写回新值。
  2. 原子操作:假设有操作A和操作B,如果从执行A的角度看,当其他的线程执行B时,要么B全部执行完成,要么B一点都没有执行,这样A和B互为原子操 作,一个原子操作是指:该操作对于所有的操作包括他自己都满足前面描述的状态。java.util.concurrent.atomic包中包含了原子类。

  1. 一个synchronized代码块包括两个部分:锁对象的引用,以及这个锁保护的代码块. 
  2. 每个java对象都隐式的扮演了一个同步锁的角色,称为内部锁或者监视器锁。执行线程进入synchronized块之前会自动获得锁;而无论线程正 常退出还是在块中抛出异常都会自动释放锁。获得内部锁的唯一途径就是进入这个内部所保护的同步方法或者同步代码块。
    • 内部锁在java中扮演了互斥锁的角色。意味着至多有一个线程可以拥有锁,其他线程访问必须等待或者阻塞
    • 内部锁是可重入的,因此线程在获取他自己占有的锁时,请求会成功。重新进入意味着所有请求是“每线程”的而不是“每调用”的。重新进入 的实现是通过为每个锁关联一个请求计数器和一个占有他的线程。当技术为0认为锁是未被占有的,线程请求一个未被占有的锁的时候,jvm将记录锁 的占有者,并将请求计数置为1,如果同一个线程再次请求这个锁,计数将递增。线程退出则递减。计数为0的时候锁被释放。

共享对象

  1. 重排序:在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,它需要满足以下两个条件: 

    • 在单线程环境下不能改变程序运行的结果; 
    • 存在数据依赖关系的不允许重排序

    这两点可以归结于一点:无法通过happens-before原则推导出来的,JMM允许任意的排序。因此,在单线程中只要排序不会对结果产生影响,那么 就不能保证其中的操作一定是按照程序写定的顺序执行的。

  2. as-if-serial:意思是,所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都 必须遵守as-if-serial语义。注意as-if-serial只保证单线程环境,多线程环境下无效。

  3. 以下情况对过期数据尤其敏感:当一个线程调用了set,而另一个线程此时正在调用get,它可能就看不到最新的数据了。

  4. java存储模型要求存储和获取操作都是原子的,但是对于非volatile的64位数值变量(double,long),jvm允许将64位的数值读或写分为两个32位 的操作。如果读和写发生在两个不同的线程,这种情况读取一个非volatile的64位数值可能会得到一个值的高32位和另一个值的低32位。因此,在多线 程环境下使用共享的,可变的long和double变量需要声明为volatile的或者用锁保护起来

  5. 锁不仅仅是关于同步和互斥的,也是关于内存可见的。为了保证所有线程都能看到共享的,可变变量的最新值,读取和写入线程必须使用公 共的锁同步

  6. volatile是轻量级的同步实现,它确保对一个变量的更新以可预见的方式告知其他线程,当一个域声明为volatile的时候,编译器和运行时会监视这 个变量:它是共享的。而且对他的操作不会与其他的内存操作一起被重排序。正确使用volatile的方式包括:用于确保它们所引用的对象状态的可见性, 或者用于标识重要的生命周期事件(比如初始化或关闭)的发生。

  7. 加锁可以保证可见性和原子性,volatile只能保证可见性。只有满足下面的标准之后才可以使用volatile:

    • 写入变量的时候并不依赖变量的当前值;或者能够确保只有单一的线程修改变量的值。
    • 变量不需要与其他的状态变量共同参与不变约束
    • 访问变量时,没有其他的原因需要加锁
  8. ThreadLocal允许你将每个线程与持有数值的对象关联到一起。ThreadLocal提供了get与set方法,为每个使用它的线程维护一份单独的拷贝。所以 get总是返回当前线程通过set设置的最新值

  9. 不可变对象永远是线程安全的。不可变性不等于将对象中所有的域都声明为final类型的,所有域都是final类型的对象仍然是可以改变的,因为 final域可以获得一个到可变对象的引用。只有满足如下状态,一个对象才是不可变的。

    • 它的状态不能在创建后被修改
    • 所有域都是final类型
    • 被正确创建(创建期间没有发生this引用逸出)
  10. 为了安全的发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过如下条件安全的发布:

    • 通过静态初始化器初始化对象的引用
    • 将它的引用存储到volatile域或者AotomicReference
    • 将它的引用存储到正确创建的对象的final域中
    • 或者将它的引用存储到由锁保护的域中 

构建块

  1. 同步容器类包括两部分:一个是VectorHashTable,他们是早期JDK的一部分;另一个是他们的同系容器--同步包装类。这些类是Collections.synchronizedXXX工厂方法创建的。这些类通过封装他们的状态,并对每一个公共方法进行同步而实现了线程安全。
  2. Cellction进行迭代的标准方式是使用Iterator,无论是显式的还是通过for-each语法,但是当有其他线程并发修改容器的时候,使用迭代器仍不可避免的需要在迭代期间对容器进行加锁。在设计同步容器返回的迭代器时,并没有考虑到并发修改的问题,他们是及时失败(fail-fast)的--即当他们觉察到容器在迭代开始后被修改会抛出一个ConcurrentModificationException(此异常可能出现在单线程的代码中,当对象不是通过Itrator.remove,而是被直接从容器中删除的时候,就会出现此异常)。
  3. 并发容器:jdk5通过提供几种并发的容器来改进同步容器。同步容器通过对容器的所有状态进行串行访问,从而实现了他的线程安全,代价是削弱了并发性,当多个线程共同竞争容器级的锁时,吞吐量就会降低。
  4. 以下是几个常用集合的并发实现:
    • CopyOnWriteArrayListList的相应的同步实现。
    • CopyOnwriteArraySetSet的一个并发替代品
    • ConcurrentHashMapHashTable的并发替代品
    • ConcurrentSkipListMap作为同步的SortedMap的替代品,
    • ConcurrentSkipListSet作为同步的SortedSet的替代品。
  5. 写入时复制(Copy-On-Write)类的容器避免了迭代期间对容器的加锁和复制,他们的线程安全性来自这样一个事实:只要有效的不可变对象被正确的发布,那么访问他将不再需要额外的同步。在每次修改时,他们会创建并重新发布一个新的容器拷贝以此来实现可变性。写入时复制容器的底层只保留一个底层基础数组的引用。这个数组作为迭代器的起点永远不会被修改,因此对他的同步只不过是为了保证数组内容的可见性。显而易见,每次容器改变时复制基础数组需要一定的开销,特别是当容器较大的时候。当容器迭代的频率远远高于对容器修改的频率时,使用"写入时复制"是个合理的选择。
  6. JDK5还增加了两个新的容器类型,QueueBlockQueue
    • Queue用来临时保存正在等待被进一步处理的一系列元素,JDK提供了几种实现,包括一个传统的FIFO队列ConcurrentLinkedQueue;一个 非并发的具有优先级的队列PriorityQueueQueue的操作并不会阻塞,LinkedList就实现了Queue,但是Queue更高效。
    • BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作,阻塞队列在生产者-消费者模式中非常有用。类库中包含一些BlockingQueue的实现,其中LinkedBlockingQueueArrayBlockingQueue是FIFO队列,与LinkedListArrayList相似,但是他拥有比同步List更好的并发性能。PriorityBlockingQueue是一个按优先级顺序排序的阻塞队列,最后一个阻塞队列的实现是SynchronousQueue,他根本上不是一个队 列,因为他不会为队列元素维护任何存储空间,不过,他维护一个排队的线程清单,这些线程等待把元素加入(enqueue)或者移出(dequeue)队列。
  7. JDK6新增了两个容器类型,DeQue(发音deck)和BlockingDeque,他们分别扩展了QueueBlockingQueueDeque是个双端队列,它允许高效的在 头和尾分别进行插入和移除,实现他们的分别是ArrayDequeLinkedBlockingDeque。正如阻塞队列适用于生产者-消费者模式一样,双端队列使他们 自身与窃取工作模式相连。在窃取工作模式的设计中,每一个消费者都有一个自己的双端队列,如果一个消费者完成了自己的书双端队列中的全部任务, 他可以偷取其他消费者双端队列末尾的任务,确保每一个线程都保持忙碌状态。因为工作线程不会竞争一个共享的工作队列,因此窃取工作模式比传统的 生产者-消费者模式有更好的伸缩性。
  8. 线程可能因为几种原因被阻塞和暂停:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep唤醒,或是等待另一个线程的计算结果。当一个线程阻塞的时,它常被挂起,并被设置成线程阻塞的某个状态(BLOCKED,WAITING或是TIMED_WAITING)。BlockingQueue的put和take方法会抛出一个受检查的InterruptedException,这与类库的其他方法是相同的,比如Thread.sleep,当一个方法能够抛出InterruptedException的时候,是告诉你这个方法是一个可阻塞方法
  9. Synchronier是一个对象,他根据本身的状态调节线程的控制流。阻塞队列可以扮演一个Synchronier的角色:其他类似的Synchronier包括信号量(semaphore),关卡(barrier),以及闭锁(latch)。
  10. 闭锁:可以延迟线程的进度直到线程到达终止状态。一个闭锁工作起来就像一道大门,直到闭锁到达终点状态之前门一直关闭,没有线程能通过,一旦闭锁到达终点状态,门打开,允许所有线程通过。一旦闭锁到达终点状态,他的状态就不可以再改变了,会永远保持敞开状态。CountDownLatch是一个灵活的闭锁实现,允许一个线程或多个线程等待一个事件集的发生。闭锁的状态包括一个计数器,初始化为一个整数,用来表现需要等待的事件数。countDown方法对计数器进行减操作,表示一个时间已经发生了,而await等待计数器为0,此时所有需要等待的事件都已发生,如果计数器值非零,await会一直阻塞知道计数器为0,或者等待线程终端或超时。
public class TestCountDownLatch {
   public long tasks(int nThreads, final Runnable task) throws InterruptedException {
       final CountDownLatch startLatch = new CountDownLatch(1);//计数器初始化为1,控制主线程的状态
       final CountDownLatch endLatch = new CountDownLatch(50);//计数器初始化为工作线程的数量50,控制工作线程的状态
       for (int i = 0; i < nThreads; i++) {
           Thread t = new Thread() {
               public void run() {
                   try {
                       startLatch.await();//每个线程都必须先等待startLatch打开,确保所有线程都准备好才开始工作
                       try {
                           task.run();
                       } finally {
                           endLatch.countDown();//每个线程的最后一个工作就是为endLatch减一
                       }
                   } catch (InterruptedException e) {
                   }
               }
           };
           t.start();
       }
       long start = System.currentTimeMillis();
       startLatch.countDown();//startLatch减一之后,不再阻塞,线程开始执行
       endLatch.await();//endLatch阻塞到所有线程执行完任务
       long end = System.currentTimeMillis();
       return end - start;
   }
}

  

  1. FutureTask同样可以作为闭锁。FutureTask的计算结果通过Callable实现,他等价于一个可携带结果的Runable,并且有三个状态:等待,运行和完成(包括所有计算以任意方式结束,如正常结束,取消,异常),一旦FutureTask进入完成状态,他会永远停止在这个状态。 future.get的行为依赖于任务的状态,如果他已经完成,get可以立即得到返回的结果,否则会被阻塞直到任务转入完成状态,然后会返回结果或者抛出异常。FutureTask把计算的结果从运行的线程传送到需要结果的线程,这种传递是线程安全的。Executor框架使 用FutureTask来完成异步任务。
  2. 信号量:Semaphore用来控制能够同时访问某特定资源的活动的数量,或者同时执行某一给定操作的数量。信号量可以用来实现资源池或者给一个容器限定边界。一个semapahore管理一个有效的许可集:许可的初始量通过构造函数传递给semaphore,活动能够获得许 可(只要还有剩余许可),并在使用之后释放许可。如果已经没有许可了,acquire会被阻塞,直到可用为止(或者直到被中断或者超时),release方法向信号量semaphore返回一个许可。
  3. 关卡:关卡类似于闭锁(闭锁是一次性使用对象,一旦到达终态就不能被重置了)。他们都能够阻塞一组线程,直到某些事件发生,其中关卡与闭锁的关键不同在于,所有的线程都必须同时到达关卡点,才能继续处理。闭锁等待的是事件,关卡等待的是其他线程。 CyclicBarrier允许一个给定数量的成员多次集中在一个关卡点。当线程到达一个关卡点时,调用await,await会被阻塞,直到所有线程都到达关卡点。如果所有线程都到达了关卡点,关卡就被成功突破,这样所有线程都被释放,关卡会重置以备下一次使用。通过对await的调用 超时,或者阻塞中的线程被中断,那么关卡就被认为是失败的,所有对await未完成的调用都通过BrokenBarrierException终止。如果成功的通过关卡,await为每个线程返回一个唯一的到达索引号,可以用它来选举产生一个领导,在下一次迭代中承担一些特殊的工作。关卡通常 被用来模拟这样一种情况:一个步骤的计算可以并行完成,但是要求必须完成所有与一个步骤相关的工作后才能进入下一步。Exchanger是关卡的另一种形式,它是一种两步关卡,在关卡点会交换数据。
  4. 并发诀窍清单:
    • 所有并发问题都归结为如何协调访问并发状态,可变状态越少,保证线程安全就越容易。
    • 尽量将域声明为final的,除非他们的需要是可变的。
    • 不可变对象天生是线程安全的。不可变对象极大的减轻了并发编程的压力,他们简单而安全,可以在没有锁和防御性复制的情况下*的共享。
    • 封装使管理复杂度变得可行。在对象中封装数据,是他们能够更加容易的保持不变,在对象中封装同步,使他能更加容易的遵守同步策略。
    • 用锁来守护每一个可变变量
    • 对同一不变约束中的所有变量都使用相同的锁
    • 在运行复合操作期间持有锁
    • 在非同步的多线程情况下,访问可变变量的程序是存在隐患的
    • 不要依赖于可以需要同步的小聪明
    • 在设计过程中就考虑线程安全,或者在文档中明确的说明他不是线程安全的
    • 文档化你的同步策略

取消和关闭

  1. 在java中没有哪一种用来停止线程的方式是绝对安全的,因此没有哪一种方式用来优先停止任务。
  2. interrupt方法中断目标线程,并且isInterrupted返回线程的中断状态。静态的interrupted方法名并不理想,它仅仅能够清楚当前线程的中断状态,并返回它之前的值;这是清除中断状态的唯一方法。
  3. 阻塞库函数,如Thread.sleep和Object.wait试图检测线程何时被中断,并提前返回。他们对中断的响应的表现为:清除中断状态,抛出InterruptedException;这表示阻塞操作因为中断的缘故提前结束。当线程在并不处于阻塞的状态 下被中断时,会设置线程的中断状态,然后一直等到被取消的活动获取中断状态,来检查是否发生了中断。如果不触发InterruptedException,中断状态会一直保持,直到有人去清除中断状态。
  4. 调用interrupt并不意味着必然停止目标线程正在进行的工作,他仅仅是传递了一个请求中断的消息。线程会在下一个方便的时刻中断(取消点).有一些方法对这样的请求很重视,比如wait,sleep,join,当他们收到一个这样的请求会抛出 一个异常,或者进入时中断状态已经被设置了。
  5. 中断通常是实现取消最好的选择。
  6. 在线程池中中断一个工作者线程,意味着取消当前任务并关闭工作线程。