《Java并发实战》读书笔记(一)

第1章 简介

1.1 并发历史

  计算机发展早期,计算机作为一种昂贵的大型资源,被专业人员使用。最早的计算机没有操作系统,比如纸带机。它们运行程序时,只能一个程序运行完之后才能运行另一个程序。宝贵的计算资源经常被浪费在BUG中。于是,出现了预编译系统,分时系统,以至于现代操作系统。

1.2 线程的优势
  • 可发挥多处理器的强大能力
  • 建模的简单性:为模型中的每个类型任务都分配一个线程,极大简化异步转同步工作流的难度
  • 异步事件简化处理
  • 响应更灵敏的用户界面
1.3 并发带来的风险
  • 安全性:在没有充分的同步保证中,线程会由于无法预料的数据变化而发生错误
  • 活跃性: 线程会带来比如死锁、饥饿、活锁等问题
  • 性能问题:设计良好的程序中,线程能带来性能提升。但无论如何,线程会带来一定程度的开销。

第一部分:基础知识

  重点介绍并发性和线程安全性的基本概念,以及如何使用类库提供的基本并发工具来构建线程安全类。

第2章 线程安全性

  总述:本章讲述什么是线程安全性,以及如何使用同步机制来避免多个线程在同一时刻访问相同的数据,以保证线程安全。并介绍了线程安全带来的相关问题。
  总的来说,一个对象是否需要成为线程安全的,取决于它是否被多个线程访问。而要编写线程安全的代码,核心在于对状态访问操作进行管理。在Java中,主要的同步机制是Synchronized,以及显式锁、原子变量又及Volatile。当然,同步也会带来性能问题,我们所有做的,首先是使代码正确运行,然后再提高代码的速度。

2.1 什么是线程安全性

  定义:当多个线程访问某一个类时,不管运行时环境采用体积调度方式或者这些线程将如何交替执行,并且在主调代码中不发任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
  无状态对象一定是线程安全的。

2.2 原子性
  • 并发编程中,由于多个线程执行顺序问题而出现不正确结果,那么将产生竞态条件。常见的竞态条件是非同步的复合操作,比如:检查——执行,读取——赋值——写入。
    检查——执行:比如延迟初始化
    读取——赋值——写入:自增操作
2.3 加锁操作

  每个Java对象都可作为一个实现同步的锁,即内置锁或监视器锁。内置锁是一种互斥的独占锁,用这种锁来支持原子性时,是支持重入的。
  可重入的定义:一个线程能成功访问自己持有的锁。

2.4 如何用锁来保护状态
  • 当使用锁来协调对某个变量的访问,那么在访问变量的所有位置上都要使用同一个锁(读写)。
  • 对于包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护
2.5 活跃性与性能
  • 同步机制会带来性能问题,简单的加锁往往会与性能发生矛盾。
  • 建议,持有锁的时间不要过长。

第3章 对象的共享

  总述:本章介绍如何共享和发布对象,从而使它们能够安全的由多个线程同时访问。

3.1 可见性

  在没有同步的情况下,编译器、处理器等可能对代码执行顺序进行重排序,这将导致一些意想不到的结果。

  • 读取到失效数据:在每次访问时没有使用同一个锁,那么将可能看到失效数据
  • 非原子的64位操作:64位数据写入是两个32位写入,所以非Volatile类型的64位变量,可能也会产生失效数据
  • 加锁与可见性:加锁不仅保证了互斥,还保证了内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读、写操作的线程,都必须在同一个锁上同步。
  • Volatile变量:轻量级同步机制,Volatile不会被CPU缓存在寄存器或者其他CPU不可见的内存地方(缓存),这保证了内存可见性,但不能保证操作的原子性。
    何时使用Volatile:
    1.对变量的写入不依赖当前值,或者确保只有一个线程写入
    2.在访问变量时不需要加锁
    3.不与其他状态变量一起纳入不变性
3.2 发布与逸出
  • 发布:使对象能够被其他作用域的代码使用
    发布对象的方法:
    1.将对象的引用保存到一个公有的静态变量中,任何类和线程都以访问
    2.发布对象时,该对象的非私有域中引用的所有对象也会被发布
    3.构造方法中发布一个内部的类实例,比如创建一个线程并启动

  • 逸出:当某个不应被发布的对象被发布后,即称为逸出

3.3 线程封闭

  当访问共享数据时,通常需要同步。如果仅在单线程内访问数据,就不需要同步,这就是线程封闭技术。

  • Ad-hoc线程封闭:一种针对特定系统的线程封闭方案,维护线程封闭性的职责完全由程序实现来承担。没有语言特性来支持这种方案,所有Ad-hoc非常脆弱。
      通常,如果要将某个特定的子系统实现为一个单线程子系统,例如GUI框架,那么可采用Ad-hoc线程封闭技术。此种情况下,该系统的简便性更为突出,往往可以忽略Ad-hoc线程封闭技术带来的脆弱性。
      volatile变量上存在特殊的线程封闭,只要保证只有单线程进行写操作,那么就能安全的共享。

  • 栈封闭:是线程封闭的一个特例,在栈封闭中,只有通过局部变量才能访问对象。
      根据Java内存区域划分,每个线程都有自己的栈和方法计数器,所有将对象维持在栈中,那么即使该对象不是线程安全的,也能以线程安全的方式使用

  • ThreadLocal:维持线程封闭性的一种更规范方法,每个线程都保存着目标共享变量的一个副本

3.4 不变性

  如果某个对象在创建后其状态就不可改变,那么这个对象就是不可变对象。
  不可变对象一定是线程安全的。
  Final域:可以确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并 在共享这些对象时无须同步。

3.5 安全发布

  在某些情况下,我们希望多个线程共享对象,此时必须保证安全的发布

  • 不正确的发布,将导致正确的对象被破坏。没有足够的同步,共享数据时将发生不可预测的结果

  • 不可变对象与初始化安全:即使某个对象的引用对其他线程可见,也并不意味着对象的状态对于使用该线程来说一定是可见的。由于不可变对象是一种非常重要的对象(Java类库大量使用),JMM为它提供了特殊的初始化安全性,故在发布不可变对象时,没有使用同步,也能安全的访问。

  • 安全发布的常用模式,一般来说有以下几种:
      1.在静态初始化函数中初始化一个对象引用
      2.将对象的引用保存到Volatile类型的域或者AtomicReferance对象中
      3.将对象的引用保存到某个正确构造对象的Final类型域中
      4.将对象的引用保存到一个由锁保护的域中,或者同步容器中。

  • 事实不可变对象:需要安全的发布

  • 可变对象:需要安全的发布,并且必须是线程安全的或者由某个锁保护起来,在使用时必须同步。

  • 安全地共享对象:发布时,明确说明对象的访问方式;使用时,了解共享对象的“既定规则”。

小结:

  在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
  线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改.
  只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它.共享的只读对象包括不可变对象和事实不可烃对象.
  线程安全共享:线程安全的对象在其内部实现同步,办此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步.
  保护对象:被保护的对象只能通过持有特定的锁来.保护对象包括封闭在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象

第4章 对象的组合

  总述:介绍如何将一些小的线程安全类组合成更大的线程安全类。

4.1设计线程安全的类

  三个要素:
    找出构成对象状态的所有变量
    找出约束状态变量的不变性条件
    建立对象状态的并发访问管理策略

4.2实例封闭

  将数据封闭在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁.
  例如:class A 只有一个域,final hashSet pp,由于pp是私有,并且访问pp的方法是加锁的,要访问就必须获得A的内置锁,所以这个hashset就是线程安全的,因此整个A是线程安全的
  实例封闭是构建线程安全类的一个最简单方式.
  容器类库,如ArrayList 和 HashMap,并非线程安全.但包装器工厂(例如 Collections.synchronizedList及其类似方法),通过"装饰器"模式,将容器类墙头在一个同步的包装器对象中,使得这些非线程安全的类可以在多线程环境中安全的使用.
  java 监视器模式
    该模式是一种代码编写的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态.
  通过私有锁来保护状态

4.3 线程安全性的委托
4.4 在现有的线程安全类中添加功能

  在开发中,我们有非常多的可以直接使用的类,虽然在某种情况下有些不符合我们预期的结果.因此,可以选择在这些类的基础上进行"扩展",来添加我们需要的操作与结果.当然,扩展比直接在类的源码中添加代码更加脆弱,比如底层类改变了同步策略,那么扩展类将会被破坏.

  • 客户端加锁机制
      对于使用某个对象X的客户端代码C,使用X本身用于保护其状态的锁来保护这段客户代码.要使用客户端加锁,你必须知道对象X使用的是哪一个锁.也就是说,锁住的对象应该是X,而不是使用X的C
      客户端加锁和扩展加锁非常类似,都是将扩展代码与基类耦合在一起.都是非常脆弱的一种添加原子操作的方法.
  • 组合
      类C持有非线程安全的A,所有关于A的操作,都委托给A.在需要原子操作的方法中,使用C的内置锁或者其他锁,从C的层面来提供一致的加锁策略.这种情况下,需要保证的是,A的引用只能为C持有,并且客户代码只能通过C的方法来访问A
4.5 将同步策略文档化

  在文档中说明客户代码需要的线程安全性保证,以及代码维护人员需要的同步策略

第5章 基础构建模块

  总述:介绍在平台库中提供的一些基础的并发构建模块,包括线程安全的容器类和同步工具类。

5.1 同步容器类

  Vector以及HashTable,将所有对容器状态的访问都串行化,严重降低了并发性,当多个线程竞争容器的锁时,吞吐量将严重减低.

  • 这些同步容器类,通过对每个方法加锁,使得每个方法都是线程安全的.但并发情况下,如果交替使用容器中的不同方法,导致了非原子操作,那么仍然会导致异常抛出.虽然这并不能否定Vector或者HashTable是线程安全的事实
  • 并发中使用迭代器,可选择先克隆出来,再对副本进行操作.当然,克隆的动作也是要同步的.
  • 隐式的调用迭代器,在并发情况下可能会抛出异常
5.2 并发容器:针对多线程并发访问而设计,用来代替同步容器,可以极大的提高伸缩性并降低风险
  • ConcurrentHashMap
      不同于一般的为每个方法加锁的策略,ConcurrentHashMap使用了一种粒度更细的"分段锁"机制.线程不能独占访问map.
      迭代时具有弱一致性,并非"快速失败".
      对于size和isEmpty这些需要在整个map上计算的方法,这些方法的语义被略微减弱,给出的可能是一个过时的值,实际上就是一个估计值.
      对于一些"先检查再执行"的类型在操作,该map已经实现为原子操作
  • CopyOnWriteArrayList
      每次写入时,都进行复制,然后返回一个新的数组.
      当迭代远远多于修改操作时,才应该使用这类容器
5.3 阻塞队列和生产者——消费者模式
  • 生产者——消费者模式,将”找出需要完成的工作“与“执行工作”分开,实现生产与消费的解耦,通过异步的方式,简化了开发过程。
      阻塞队列支持这种模式,它提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。它支持任意数量的生产者和消费者。
      一种常见的生产者–消费者模式就是线程池与工作队列的组合,比如Executor。
  • 串行线程封闭
  • 双端队列与工作密取
      在生产者——消费者模式中,所有消费者共享一个工作队列,而在工作密取模式中,每个消费者都有自己的一个双端队列。如果一个消费者完成了自己的全部工作,那么它可以从其他消费者的双端队列末端秘密的获取工作。工作密取模式在大多数时候只访问自己的双端队列,因此比生产者——消费者模式具有更高的可伸缩性,进一步降低了队列上的竞争程度。
5.4 阻塞方法与中断方法
5.5 同步工具类

  同步工具类可以是任何一个对象,这个对象封闭了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待。同步工具类包括但不限于:阻塞队列、信号量、栅栏、闭锁。

  • 闭锁:可能延迟线程的进度直到其到达终止状态。
      它就像一扇门,创建时保持关闭,所有线程都将被阻止通过。在某一事件触发之后,门将打开,并且将永不关闭。
      通过闭锁,可以启动一组操作,或者等待一组相关的操作结束。
      闭锁是一次性对象,当计数器为0时,锁打开,并不再会被锁或者重置
      例如:任务开始等待在某个闭锁上,当计数器为0时,多个任务同时启动.
      CountDownLatch是一种灵活的闭锁实现。它包含一个计数器,初始化时为一个正数,代表要等待的事件数。countDown()方法递减计数器,当计数器为0时,闭锁打开。
  • FutureTask:通过Callable来实现,有3种状态:等待运行,正在运行,运行结束。
      当调用Future.get()时,它的行为取决于任务的状态。如果任务已经完成,则立即返回结果,否则get()将阻塞到任务完成。
  • 信号量 Semaphore: 管理着一组虚拟的许可,许可数量通过构造函数来指定.在执行操作时,可以首先获得许可,并在使用后释放许可.
      信号量可实现资源池,也可将任何容器变成有界阻塞容器。
  • 栅栏 :类似于闭锁,能阻塞一组线程直到某个事件发生.
      与闭锁的关键区别:所有线程必须同时到达了栅栏位置,才能继续执行.闭锁用于等待事件,而栅栏用于等待其他线程.
      CyclicBarrier可以使一定数据的参与方反复地在栅栏位置汇集,在并行迭代算法中非常有用。
5.6 构建高效且可伸缩的结果缓存

  几所所有的服务器应用都会使用某种形式的缓存,重用之前的计算结果能降低延迟,提高吞吐量,但却需要消耗更多内存。

  • 利用HashMap和同步机制实现缓存
  • 使用ConcurrentHashMap代替HashMap
  • 使用FutureTask作为Map的Value

第一部分小结:

  1. 可变状态是至关重要的,所有的并发问题都可以归结为如何协调对并发状态的访问.可变状态越少,就越容易确保线程安全性
  2. 尽量将域声明为final类型,除非需要它们是可变的.
  3. 不可变对象一定是线程安全的
    不可变对象能极大的降低并发编程的复杂性.更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制.
  4. 封闭有助于管理复杂性
    在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封闭在对象中,更易于维持不变性条件;将同步机制封闭在对象中,更易于遵循同步策略.
  5. 用锁来保护每个可变变量
  6. 当保护同一个不变性条件中的所有变量时,要使用同一个锁.
  7. 在执行复合操作中,要持有锁
  8. 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题.
  9. 不要故作聪明地推断出不需要使用同步.
  10. 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的.
  11. 将同步策略文档化
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值