Java并发编程实战 读书笔记(1)

一、基础知识

2 线程安全性

要编写线程安全的代码,其核心是在于对状态访问操作进行管理,特别是对共享(Shared)的可变(Mutable)的状态的访问。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量;
  • 将状态变量修改为不可变的变量;
  • 在访问状态变量时使用同步。

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象一定是线程安全的


在并发编程中,由于不恰当的执行时序而出现的不正确的结果是一种非常重要的情况,它有一个正式的名称竟态条件(RaceCondition)
最常见的竟态条件类型就是先检查后执行(Check-Then-Act)操作,即通过一个可能失效的观测结果来决定下一步的操作。


如果一个无状态的类添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。


要保持状态的一致性,就需要在单个原子性操作中更新所有相关的状态变量。
Java提供了一种内置的锁机制来支持原子性:同步代码块(SynchronizedBlock)。

synchronized(lock){
	//访问或修改由锁保护的共享状态
}

Java的内置锁相当于一种互斥锁。


当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”

重入可以避免死锁的发生;


3 对象的共享

有一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(CriticalSection)”。同步还有另一个重要的方面:内存可见性(MemoryVisibility)


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

如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。

当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)

3.2小节没有太理解


当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(ThreadConfinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的[CPJ2.3.2]。

线程封闭技术的一种常见应用是JDBC(JavaDatabaseConnectivity)的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求(例如Servlet请求)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。

Adhoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Adhoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。

局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。

维持线程封闭性的一种更规范方法是使用ThreadLocal


满足同步需求的另一种方法是使用不可变对象(Immutable Object)

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

不可变对象一定是线程安全的

不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型 。
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)。

正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。


要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

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

4 组合对象

在设计线程安全类的过程中,需要包含以下三个基本要素:

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

5 基础构建模块

委托是创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可。

同步容器类

此外还包括在JDK 1.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx等工厂方法创建的。
这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作

容器上常见的复合操作包括:迭代(反复访问元素,直到遍历完容器中所有元素)、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算,例如“若没有则添加”(检查在Map中是否存在键值K,如果没有,就加入二元组(K, V))。
在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。
这段没有很理解

在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”(fail-fast)的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。

如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在选代期间对其进行修改,这样就避免了抛出ConcurrentModificationException(在克隆过程中仍然需要对容器加锁)。
在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。

注意:
在单线程代码中也可能抛出ConcurrentModificationException异常。当对象直接从容器中删除而不是通过Iterator.remove来删除时,就会抛出这个异常。

  • 隐藏迭代器
    toString对容器进行迭代
    容器的hashCode和equals等方法也会间接地执行迭代操作
    同样,containsAll、removeAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出Concur rentModificationException。

如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。

正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。

并发容器

在Java 5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,
以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。
在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换以及有条件删除等。

通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

Java 6引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用synchronizedMap包装的TreeMap或TreeSet)。


ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。
ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁(Lock Striping)。

ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。

ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent),而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

尽管有这些改进,但仍然有一些需要权衡的因素。对于一些需要在整个Map上进行计算的方法,例如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。
由于size返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size返回一个近似值而不是一个精确值。虽然这看上去有些令人不安,但事实上size和isEmpty这样的方法在并发环境下的用处很小,因为它们的返回值总在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括get、put、containsKey和remove等。

与Hashtable和synchronizedMap相比,ConcurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性。
只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap


CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用是替代同步Set。)

“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。

显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。


阻塞队列和生产者-消费者模式
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值