Java 并发编程实战读书笔记-第一部分

多线程并发编程

1 第一章 :为什么使用多线程

1.1 为什么使用多线程:
使用多线程能够有效的提高系统吞吐率和资源利用率。线程还可以简化JVM 的实现,垃圾回收器通常在一个或多个专门的线程中运行。多线程程序可以通过提高处理器资源利用率来提升系统吞吐率。
复制代码
1.2 同步和异步的区别:

**同步:**A调用B,B的处理是同步的,在处理完之前他不会通知A,只有处理完之后才会明确的通知A。

**异步:**A调用B,B的处理是异步的,B在接到请求后先告诉A我已经接到请求了,然后异步去处理,处理完之后通过回调等方式再通知A。

1.3 并发性注解标注

**@Immutable :**表示类是不可变的,它包含了@ThreadSafe(线程安全) 含义

**@NotThreadSafe :**表示这个类不是线程安全的

使用加锁的类中,应该指明那些状态变量由那些锁保护,以及那些锁被用于保护这些变量,一种常见的不安全性的常见原因是:某个线程安全的类一直通过加锁来保护器状态,单随后又对这个类进行了修改,并添加了一些未通过加锁来保护的新变量,或者没有正确的枷锁保护先有个状态变量的新方法。

使用@GuardeBy 注解来说明那些变量通过那些锁进行加锁,避免这些疏忽。

@GuardedBy(lock),这意味着有保护的字段或方法只能在线程持有锁时被某些线程访问。 我们可以将锁定指定为以下类型:

  • this : 在其类中定义字段的对象的固有锁。
  • class-name.this : 对于内部类,可能有必要消除“this”的歧义; class-name.this指定允许您指定“this”引用的意图。
  • itself : 仅供参考字段; 字段引用的对象。
  • field-name : 锁对象由字段名指定的(实例或静态)字段引用。
  • class-name.field-name : 锁对象由class-name.field-name指定的静态字段引用。
  • method-name() : 锁对象通过调用命名的nil-ary方法返回。
  • class-name :指定类的Class对象用作锁定对象。

2 第二章 :线程安全性的保证

2.1 什么是线程安全性

线程安全类的定义:当某个线程访问某个类时,这个类始终都能表现正确的行为(保证线程的正确性),这个类就称为是线程安全的。操作方法的顺序执行。

实现线程安全类的好处:在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

**注:**线程安全类:如果正确实现了某个对象,那么在任何的操作中调用对象的公有方法或者对其公有域进行读写操作。都不会违背不变性的条件或后验条件,在线程安全类的对象实例上执行的任何串行化或并行操作都不会是对象处于无序状态。

Servlet 框架实现无状态对象的线程安全的原理:类对象既不包含任何域对象也不包含对其他类域的引用。方法执行的变量只存在于栈中,每次线程对变量修改都是对栈中内容的修改,每次产生一个新的请求(线程)就产生了该线程对应的新栈。两个栈中的变量不相互影响。因此是线程安全的。这种现象叫做无状态现象无状态对象一定是线程安全的。

2.2 竞态条件

竞态条件:当某个计算的正确性取决于多个线程交替执行的时序时,那么就会发生竞态条件。常见的一种竞态条件:**先检查后执行操作。**基于对象之前的状态来定义对象状态的转化,修改该对象时,我们要确保在执行更新过程中没有其他线程使用或修改这个值,不然会发生各种问题(数据被覆盖、文件被破坏)

2.3 Atomic 的原子类变量

通过无状态引申出来的原子类变量,用于实现数据和对象引用上的原子状态转换。

重温:线程安全性的定义:线程安全是指多线程之间的操作无论采用何种执行时序或交替方法,都要保证不变性的条件不被破坏。要保证状态的一致性,我们需要在原子性操作中更新我们所有的状态。

2.4 内置锁-Synchronized

Synchronized :修饰同步代码块,每个java代码块都可以用作一个实现同步锁,这些锁被称为内置锁或监视器锁,线程会在进行程序之前自动获取锁,代码执行完成后自动释放锁。由jvm 自动进行管理。具有互斥性和可重入性。

**重入性:**线程当试图获取一个已经由自己所持有的锁时,这个请求时成功。**重入的实现方式:**为每个锁关联一个

获取计数值和一个所有者线程。当计数器中的值为0时,表明该资源没有被任何线程使用,当一个线程请求一个未被持有的锁时,jvm 会记下锁的持有者,并且将会获取的计数器值相应的修改,当线程退出同步代码块时,计数器会相应的递减,为0时,表示锁资源释放掉。

2.4 不良并发

不良并发指:可同时调用的数量,不仅受到可用处理的资源的限制,还受到应用程序本身结构的限制。我们可以通过缩小同步代码块的作用范围,确保Servlet 的并发性,同时又维护线程的安全性。同步代码块 要包含操作的原子性。将不影响共享状态但执行时间较长的操作从同步代码块中分离出去。

3 第三章:对象的共享

3.0 概述

对于正确的并发程序来说:关键的问题在于-在访问共享的可变状态是需要进行正确的管理。我们通过同步加锁等方式避免了多个线程在同一时刻访问相同的数据。本章通过介绍如何共享和发布对象,从而使他们能够安全的由多个线程同时访问,以上两章是线程安全类和concurrent 类库构建并发应用程序的重要基础。

3.1 可见性

当访问某个共享且可变的变量时,要求所有的线程在同一锁上同步,确保某个线程写入该变量的值对于某个线程都是可见的,否则。如果一个线程未持有正确锁的情况下读取某个变量。那么读到的可能是一个失效值。

**加锁的含义:**加锁不仅仅局限于互斥行为,还包含内存的可见性,为了确保所有线程都能获取共享变量的最新值,对该变量的所有读操作、写操作都必须在同一个锁上同步。

3.2 volatile 关键词

**volatile 可见性:**java语言提供的一种同步机制,能够将该修饰符修饰的变量告知编译器此变量是共享的,volatile 变量不会被缓存到寄存器或者其他处理器不可见得地方,因此对volatile 变量总会返回当前的最新被修改的值。是一种轻量级同步机制。

**volatile 有序性:**有序性是指禁止JVM 指令重排优化,程序执行代码的顺序是按照我们书写顺序执行的。

注:加锁即可以保证可见性又可以保证原子性,volatile 只能保证可见性和有序性,不能保证原子性。

volatile 使用场景:

  • 当运算结果不依赖变量当前的值,或者能确保只有单一线程修改变量的值的时候,我们才可以对该变量使用volatile关键字
  • 变量不需要与其他状态变量共同参与不变约束
3.3 对象的发布和逸出

**发布对象:**使对象能够在当前作用于发布之外的代码中使用。

**对象逸出:**当某个不应该发布的对象被发布时,这种情况被称为对象逸出。

3.4 线程封闭

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

1622427831804.png

3.5 ThreadLocal 类

ThreadLocal 类可以为每一个线程创建一份独立的副本,因此get总能返回由当前执行线程在调用时set 设置的最新值。当某个频繁操作需要一个临时对象,如一个缓冲区,而同时又希望避免每次执行时都重新分配该临时对选哪个,就可以采用该项技术。

**实现原理:**在ThreadLocal类中有一个Map,用于存储每一个线程的变量副本,Map中元素的键为线程对象,而值对应线程的变量副本。自己实现一个简单的版本

public class MyThreadLocal
{

    private final ConcurrentHashMap<Thread, Object> valueMap = new ConcurrentHashMap<>();
	public void set(Object newValue)
	{
		valueMap.put(Thread.currentThread(), newValue);
	}

	public Object get()
	{
		Thread currentThread = Thread.currentThread();

		Object o = valueMap.get(currentThread);

		if (o == null && !valueMap.containsKey(currentThread))
		{

			o = initialValue();

			valueMap.put(currentThread, o);

		}

		return o;
	}

	public void remove()
	{
		valueMap.remove(Thread.currentThread());

	}

	public Object initialValue()
	{
		return null;
	}
}
复制代码
3.6 不可变性–final域

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

不可变对象的满足条件:

  • 对象创建后其状态不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的(对象的创建期间,this引用没有逸出)
3.7 安全发布对象

发布对象:我们需要对象在多个线程中共享,必须要保证安全的进行共享。

安全发布的常用模式:

1622429875322.png

不同对象的发布方式:

1622429999400.png

如何安全的共享对象:

1622430084811.png

4 第四章:对象的组合

4.1 设计线程安全的类

同步策略:定义了如何在不违背对象的不变的条件,或后验条件的情况下对其状态的访问操作进行协同。规定了如何将不可变性、线程封闭与加锁机制等结合起维护线程的安全性。并且规定了那些变量由那些锁来保护。

4.1.1 收集同步的需求
4.1.2 依赖状态的操作

​ 类的不变性条件和后验条件约束了在对象上有那些状态和状态的转换是有效的。在对象中还包含一些基于状态的先验条件。**并发时,**先验条件可能会由于其他线程的执行而错误的变为真,因此在并发程序中要一直等到先验条件为真。然后在执行该操作。

4.1.3 状态的所有权
**所有权和封装性的关系:**对象封装它拥有的状态,反之成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议去维持变量状态的完整性。所有权意味着控制权。然而,发布了某个对象的引用,那么就不在拥有完全控制,最多是:“共享控制权”。**对于构造函数或者从方法中传递进来的对象,类通常不拥有这些对象,**除非这些方法是被专门设计转移传递进来对象的所有权(例如:同步容器的工厂方法)
复制代码

4.2 实例封闭

4.2.1 概述

​ **实例封闭:**指将非线程安全的对象,在多线程的程序中只能由单个线程访问。或者通过一个锁来保护对象的所有访问。封装简化了线程的安全的实现过程:将非线程安全得对象封装到线程安全的对象里。通过将封闭机制与合适的加锁策略结合起来。从而能够确保线程安全的方式来使用非线程安全的对象。

**java中的实例应用:**例如:ArrayList 和HashMap 并不是线程安全的,但是java中提供了一些包装工厂方式(例如:Colleections.synchronizde 及其类似方法)。使得这些非线程安全的类可以在多线程的环境中安全使用。这些包装类将容器类封装到一个同步的包装器对象中。而包装器将接口的每个方法都实现称为同步方法。并将请求转发到底层的容器对象中,只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封装到包装器中)那么被包装的就可以说是线程安全的。(保证包装类方法的线程安全性),

4.3 线程安全性的委托

​ 大多数对象都是组合对象,当从头开始构建一个类,或者将多个线程安全类组合为一个类是,我们是否需要在增加一个额外的线程安全层?视情况而定:主要看是否存在相互影响,还是个个的单独使用。

4.4 在现有的线程安全类中添加功能

​ java 类库中存在许多有用“基础模块”,我们应该选择重用这些现有的类而不是创建新的类,重用降低开发代码的工作量、开发风险(因为现有的类都经过了测试)

4.4.1 使用客户端进行操作

​ 即对已有的类在进行封装,实现对功能的添加。

非线程安全的实现:
1624441136935.png
线程安全的实现:

1624439832342.png

4.4.2 组合

1624439858721.png

4.5 将同步策略写成文档

​ 定义:同步策略(线程安全策略),Synchronized、volatile或者任何一个线程安全类都应对应某种同步策略。用于在并发访问时确保数据的完整性,这种策略是程序设计的要素之一。

需考虑的方面:

一、将那些变量声明为volatile 类型,哪些变量用锁来保护、哪些锁保护哪些变量,哪些变量必须是不克兵的或者被封装在线程中,那些操作必须是原子性操作等等。其中某些方法是严格的实现细节,必须将他们文档化,以便日后的维护。

二、保证将类中的线程安全性文档化,指明该类是否是线程安全的,执行回调时是否持有一个锁,是否某些特定的锁会影响其行为。是否支持客户端加锁,客户端添加原子性操作时,指定需要获取到哪些锁,才能保证原子性操作。

5 第五章:基础构建模块

​ 第四章介绍了构造线程安全类的一些技术:如将线程安全性委托给现有的线程安全性,委托是创建线程安全类的一个有效策略:只需让现有的线程安全类管理所有的状态即可。

5.1 同步容器类

同步容器类包括:Vector 和Hashtable,二者是JDK1.2 中添加的功能相似的类,这些同步封装器是由Collections.synchroizedXxx 等工厂方法创建的,这些类实现线程安全的方式:将他们的状态封装起来,并对每个共有方法进行同步。使得每次只有一个线程能访问容器的状态。

5.1.1 同步容器类的问题

​ 同步容器类都是线程安全的,但是某些情况下需要额外的客户端加锁来保护复合操作。容器上常见的复合操作:迭代(反复访问元素,之道遍历完容器中所有的元素)、跳转(根据指定顺序找到哦啊当前元素的下一个元素)以及条件运算。例如 “若没有则添加”,这些复合操作在没有客户端加锁的情况下依然是现场安全的,但当其他线程并发的修改容器时,他们可能会出现错误。

5.1.2 迭代器与ConcurrentModificationException(并发修改异常)

对容器的迭代标准方式都是使用Iterator ,在设计同步容器类的迭代器时没有考虑多并发修改的问题。因此他们表现出的行为是”及时失败的”,这意味着,当他们发现容器在迭代中被修改时,就会抛出一个ConcurrentModificationException异常。

5.2 并发容器

​ jdk1.5 以后提供了多种容器来改进同步容器的性能,同步容器:对所有容器的状态访问都串行化,以实现它们的线程安全性,这种方式降低了并发性。当多个现场竞争容器锁时,吞吐量将会严重降低。

5.2.1 ConcurrentHashMap

​ 与HashMap 一样,ConcurrentHashMap 也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap 并不是将每个方法都在同一个锁上同步并使得每次只有一个线程访问容器,而是使用一种更加细粒度的加锁机制来实现更大程度上的共享,这种机制被称为分段锁(Lock Striping).这种机制中,任意数量的读取线程可以并发的访问Map,执行读取操作的线程和执行写入操作的线程可以并发的访问Map,并且一定数量的写入线程可以并发的修改Map.ConcurrentHashMap 带来的结果是:在并发访问的环境中将实现更高的吞吐量,在单线程的环境中只损失很少的性能。

​ **什么是分段锁:**在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。

我们一般有三种方式降低锁的竞争程度:

1、减少锁的持有时间

2、降低锁的请求频率

3、使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。

当然,任何技术必有其劣势,与独占锁相比,维护多个锁来实现独占访问将更加困难而且开销更加大。

下面给出一个基于散列的Map的实现,使用分段锁技术。

import java.util.Map;

/**
 * Created by louyuting on 17/1/10.
 */
public class StripedMap {
  //同步策略: buckets[n]由 locks[n%N_LOCKS] 来保护

  private static final int N_LOCKS = 16;//分段锁的个数
  private final Node[] buckets;
  private final Object[] locks;

  /**
   * 结点
   * @param <K>
   * @param <V>
   */
  private static class Node<K,V> implements Map.Entry<K,V>{
    final K key;//key
    V value;//value
    Node<K,V> next;//指向下一个结点的指针
    int hash;//hash值

    //构造器,传入Entry的四个属性
    Node(int h, K k, V v, Node<K,V> n) {
      value = v;
      next = n;//该Entry的后继
      key = k;
      hash = h;
    }

    public final K getKey() {
      return key;
    }

    public final V getValue() {
      return value;
    }

    public final V setValue(V newValue) {
      V oldValue = value;
      value = newValue;
      return oldValue;
    }

  }

  /**
   * 构造器: 初始化散列桶和分段锁数组
   * @param numBuckets
   */
  public StripedMap(int numBuckets) {
    buckets = new Node[numBuckets];
    locks = new Object[N_LOCKS];

    for(int i=0; i<N_LOCKS; i++){
      locks[i] = new Object();
    }

  }

  /**
   * 返回散列之后在散列桶之中的定位
   * @param key
   * @return
   */
  private final int hash(Object key){
    return Math.abs(key.hashCode() % N_LOCKS);
  }


  /**
   * 分段锁实现的get
   * @param key
   * @return
   */
  public Object get(Object key){
    int hash = hash(key);//计算hash值

    //获取分段锁中的某一把锁
    synchronized (locks[hash% N_LOCKS]){
      for(Node m=buckets[hash]; m!=null; m=m.next){
        if(m.key.equals(key)){
          return m.value;
        }
      }
    }

    return null;
  }

  /**
   * 清除整个map
   */
  public void clear() {
    //分段获取散列桶中每个桶地锁,然后清除对应的桶的锁
    for(int i=0; i<buckets.length; i++){
      synchronized (locks[i%N_LOCKS]){
        buckets[i] = null;
      }
    }
  }
}
复制代码

上面的实现中:使用了N_LOCKS个锁对象数组,并且每个锁保护容器的一个子集,对于大多数的方法只需要回去key值的hash散列之后对应的数据区域的一把锁就行了。但是对于某些方法却要获得全部的锁,比如clear()方法,但是获得全部的锁不必是同时获得,可以使分段获得,具体的查看源码。

这就是分段锁的思想。

5.2.2 CopyOnWriteArrayList

用于替换同步list,写时复制容器的线程安全性在于:只要正确的发布一个事实上不可变的对象,那么在访问该对象时就不在需要进一步同步。在每次修改的是,都会创建并重新发布一个细腻的容器副本,从而实现可变性。**写入时复制容器**,的迭代器保留一个指向基础数组的引用,这个数组档期位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需保障数组内容的可见性。因此多个线程可以同时对这个容器进行迭代,二不会彼此干扰或者与修改容器的线程相互干扰。
复制代码

使用场景:当容器的迭代操作远远多于修改操作时,才应该使用“写入时复制容器”。这个准则更好的描绘出了许多的事件通知系统:在分发通知时需要迭代已注册监听的链表。并调用一个监听器。

5.3 阻塞队列和生产者-消费者模式

阻塞队列提供了可阻塞的put和take 方法,以及支持定时的offer和poll 方法。如果队列中已经满了。那么put 方法京阻塞到有空间可用;如果队列为空,那么take 方法将会阻塞之到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不充满,因此无界队列上put方法永远不会阻塞。

生成者-消费者模式:该模式将“找出需要的工作”与“执行工作”,这两个过程分离开来。并把工作项放到一个“待完成”列表中以便随后处理,而不是找到后立即处理。该模式简化开发过程,因为它消除了生成者和消费者类之间的代码依赖性,此外。该模式还将生成的数据过程和使用数据的过程解耦开来一简化工作负载的管理。

常见的队列:

  • LinkedBlockingQueue 和ArrayBlockingQueue是FIFO队列,先进先出
  • PriorityBlockingQueue 是一个按优先级排序的队列,可以自定义顺序,实现Comparable 或者继承Comparator

5.4 同步工具类

同步工具类:同步工具类包含了一些特定的结构化属性:他们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待。此外,还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入预期状态。

5.4.1 闭锁(只能使用一次)

1624531870602.png

1624531915483.png

5.4.2 信号量

1624531994765.png

1624532012168.png

5.4.3 栅栏

1624532122243.png

5.5 构建高效且可伸缩性的结果缓存

第一部分小结

  • 可变状态是至关重要的

    所有的并发问题都可以归纳成如何协调对并发状态的访问,可变状态越少,就越容易保证线程安全性

  • 尽量将域声明成final 类型,除非他们是可变的

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

    ​ 使用不可变对象能极大的降低并发编程的复杂性,它们更为简单安全,可以任意共享而无须使用枷锁或保护性复制等机制。

  • 封装有利于管理复杂性

    ​ 编写线程安全性程序时,虽然可以将所有的数据保存到全局变量中,但为什么这样做呢?将数据封装到对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。

  • 用锁来保护每个可变变量

  • 当保护同一个不变性条件中的所有变量时,使用同一个锁

  • 在执行复合操作期间,要持有锁

  • 如果从多个线程中访问同一个可变变量时没有使用同步机制,程序会出现问题

  • 要使用同步,不要自作聪明推断出不需使用同步

  • 在设计过程中考虑线程安全,或在文档中明确指出它们不是线程安全的

  • 将同步策略文档化。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享