JAVA 并发编程快速通关指南

引言

问题一: 多线程一定快吗?

答案是不一定,这是因为存在线程有创建和上下文切换的开销。
我们可以通过命令vmstat来测试上下文切换的次数,下面是利用vmstat测试上下文切换的示例:

JAVA 并发编程快速通关指南_第1张图片

CS(Content Switch)表示上下文切换的次数,从上面的测试结果中我们可以看到,上下文 每1秒切换1000多次。

**问题二:如何减少上下文切换? **

无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用锁(如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据)

  • Peterson算法(控制两个进程访问一个共享的单位用户资源而不发生访问冲突)
  • ConcurrentHashMap用桶粒度的锁,避免了put和get中对整个map的锁定,尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见的。
  • CAS算法。Java的atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

并发基础

线程安全的的3个必要条件
1. 原子性

定义: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的变量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是a++和a +=1就不是原子性操作。

Java中的原子性操作包括:

  • 基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。
  • 所有引用reference的赋值操作
  • java.concurrent.atomic.* 包中所有类的一切操作
2. 可见性

定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。

synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

Java的内存模型JMM以及共享变量的可见性:

JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

JAVA 并发编程快速通关指南_第2张图片

3. 有序性

定义:即程序执行的顺序按照代码的先后顺序执行。
Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。

Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,自然就保证了有序性。

volatile
volatile关键字特性
  1. 保证可见性,不保证原子性
    • 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去
    • 这个写会操作会导致其他线程中的缓存无效。
  2. 禁止指令重排
    重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:
volatile的使用场景
保证可见性(双重检查锁)

JAVA 并发编程快速通关指南_第3张图片

思考题:sync 已经可以保证可见性了,为什么DCL还要加volatile关键字?

new 对象的过程不是原子操作 先申请内存 初始化 再关联引用(new,dup,invokespecial),volatile是为了使这3个指令不发生重排序,如果乱序的话,别的线程就有可能存在一个时间窗口,拿到非空,但不完整的对象。

保证有序性

JAVA 并发编程快速通关指南_第4张图片
JAVA 并发编程快速通关指南_第5张图片

volatile不具有原子性

大家想一下这段程序的输出结果是多少?
也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

原因是volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
JAVA 并发编程快速通关指南_第6张图片

synchronized关键字

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。
Java中的每一个对象都可以作为锁。具体表现 为以下3种形式。

对于普通同步方法,锁是当前实例对象

public synchronized void add(int value){
    this.count += value;
}

对于静态同步方法,锁是当前类的Class对象。

public static synchronized void add(int value){
	count += value; // count位静态变量
}

对于同步方法块,锁是synchronized括号里配置的对象。

public void add(int value){
	synchronized(this){
		this.count += value;
	}
}

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

synchronized关键字特性

1、可重入性
一个对象往往有多个方法,这些方法有的是同步的,有的是非同步的,那么如果一个线程已经获得了某个对象的锁并进入了其某个同步方法,而这个同步方法中还需要调用同一实例的另一个同步方法,是否需要重新竞争锁?
这对于某些锁来说,是需要重新竞争锁的,但是我们的 synchronized 是「可重入的」,也就是说,如果当前线程获得了某个对象的锁,那么该对象的所有方法都是可以无需竞争锁式调用的。

2、可见性
但是说实话,解决内存可见性而使用 synchronized 代价太高,需要加锁和释放锁,甚至还需要阻塞和唤醒线程,我们一般使用关键字 volatile 直接修饰在变量上就可以了,这样对于该变量的读取和修改都是直接映射内存的,不经过线程本地私有工作内存的。

synchronized锁的升级过程

升级过程可简单的理解为:

偏向锁 -> 轻量级锁 -> 重量级锁

JAVA 并发编程快速通关指南_第7张图片

偏向锁

偏向锁就是指对象头中的 mark word 存储了当前线程的 ID;

检查 mark word 中的线程 id 是不是当前线程,如果是当前线程,进入同步代码块;不是就进行锁升级;

轻量级锁
  1. 当线程尝试获取锁时,会在栈中创建一个锁记录,并把锁对象的 mark word 拷贝到锁记录中;
  2. 使用 CAS 尝试将锁对象的 mark word 更新为当前线程锁记录的指针,如果成功,表示持有锁,执行同步块,如果失败执行步骤 3
  3. 线程就会自旋,重复步骤 2;如果达到一定次数,没有获取成功,就升级为重量级锁
重量级锁
  1. 将线程锁对象头修改指向 monitor 的指针,然后继续执行代码块

JAVA 并发编程快速通关指南_第8张图片

Java如何实现原子操作

(1)使用循环CAS实现原子操作
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

(2)unsafe工具类
从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子 方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和 自减1。

ABA问题

​ 因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。

从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0)

JAVA 并发编程快速通关指南_第9张图片

private static AtomicInteger atomicInt = new AtomicInteger(100);    
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);

public static void main(String[] args) throws InterruptedException {
	Thread intT1 = new Thread(new Runnable() {
		@Override
		public void run() {
			atomicInt.compareAndSet(100, 101);
			atomicInt.compareAndSet(101, 100);
		}
	});

	Thread intT2 = new Thread(new Runnable() {
		@Override
		public void run() {
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
		}
      
 			boolean c3 = atomicInt.compareAndSet(100, 101);
 			System.out.println(c3); // true
    }
  });
  intT1.start();intT2.start();intT1.join();intT2.join();
}
private static AtomicInteger atomicInt = new AtomicInteger(100);   
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);
 
	public static void main(String[] args) throws InterruptedException {
		Thread refT1 = new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					TimeUnit.SECONDS.sleep(1);
				} catch (InterruptedException e) {
				}
				atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
				atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
      }
    });
 
		Thread refT2 = new Thread(new Runnable() {
			@Override
      public void run() {
				int stamp = atomicStampedRef.getStamp();
				try {
					TimeUnit.SECONDS.sleep(2);
				} catch (InterruptedException e) {
				}
				boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
				System.out.println(c3); // false
			}
		});
		refT1.start();refT2.start();refT1.join();refT2.join();
  }

并发容器

HashMap

在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构,结构变得复杂了,但是效率也变的更高效。当一个值中要存储到Map的时候会根据Key的值来计算出他的hash,通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储 在Object源码分析中解释过,但是这样如果链表过长来的话,HashMap会把这个链表转换成红黑树来存储。
HashMap是非同步的,在JDK1.8之前,由于在向链表中插入数据时,采用的是头插法,多线程下,当链表发生扩容时,put操作会导致链表产生环,出现死循环,CPU100%

虽然jdk8后HashMap在多线程情况下扩容时不会出现死循环的情况,但是在多线程情况下仍不推荐使用HashMap。因为HashMap中的put/get方法并未加锁,这意味着无法保证上一秒put的值,下一秒取到的是原值,所以线程安全无法保证。多线程下推荐使用ConcurrentHashMap。

HashTable

Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。
Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。
Hashtable同样实现了Serializable接口,它支持序列化,实现了Cloneable接口,能被克隆。

HashTable和HashMap区别

Hashtable 中的方法是Synchronize的,而HashMap中的方法在缺省情况下是非Synchronize的。在多线程并发的环境下,可以直接使用Hashtable,不需要自己为它的方法实现同步,但使用HashMap时就必须要自己增加同步处理。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:

 Map m = Collections.synchronizedMap(new HashMap(...));

​ Hashtable 线程安全很好理解,因为它每个方法中都加入了Synchronize

ConcurrentHashMap

在jdk1.8之前ConcurrentHashMap在传统HashEntry之前增加了一个segment数组。Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,Segment数组中每一个元素就是一把锁,每一个Segment元素存储的是HashEntry数组+链表。
在jdk1.8开始,ConcurrentHashMap是由CAS和Synchronized的方式去实现高并发下的线程安全。

JAVA 并发编程快速通关指南_第10张图片

CopyOnWrite

CopyOnWriteArrayList的设计是对于线程安全容器Vector使用中出现问题的一种优化。解决了Vector在读写复合操作中因为使用了synchronized关键字的性能问题。

CopyOnWrite 的核心思想是利用读写分离,因为高并发往往是读多写少。进行读操作的时候,不加锁以保证性能;对写操作则要加锁,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可见性。

既然CopyOnWrite在进行写操作的时候要进行数组的复制,性能和内存开销比较大,因此它更适用于读多写少的操作,例如缓存。

进行写操作时尽量使用使用CopyOnWriteArrayList.addAll方法,来避免多次反复写入

CopyOnWriteMap
public class CopyOnWriteMap<K, V> implements Map<K, V>, Cloneable {
    private volatile Map<K, V> internalMap;

    public CopyOnWriteMap() {internalMap = new HashMap<K, V>();}

    public V put(K key, V value) {

        synchronized (this) {
            Map<K, V> newMap = new HashMap<K, V>(internalMap);
            V val = newMap.put(key, value);
            internalMap = newMap;
            return val;
        }
    }

    public V get(Object key) {return internalMap.get(key);}

    public void putAll(Map<? extends K, ? extends V> newData) {
        synchronized (this) {
            Map<K, V> newMap = new HashMap<K, V>(internalMap);
            newMap.putAll(newData);
            internalMap = newMap;
        }
    }
}
同步队列

在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。

  1. 使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。
  2. 非阻塞的实现方式则可以使用循环CAS的方式来实现。
阻塞队列

JDK 提供了 7 个阻塞队列。分别是:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • SynchronousQueue:一个不存储元素的阻塞队列。

  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

ArrayBlockingQueue

ArrayBlockingQueue 是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。**默认情况下不保证访问者公平的访问队列,所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可以按照阻塞的先后顺序访问队列,即先阻塞的生产者线程,可以先往队列里插入元素,先阻塞的消费者线程,可以先从队列里获取元素。**通常情况下为了保证公平性会降低吞吐量。我们可以使用以下代码创建一个公平的阻塞队列:

ArrayBlockingQueue fairQueue = new  ArrayBlockingQueue(1000,true);

访问者的公平性是使用可重入锁实现的,代码如下:

public ArrayBlockingQueue(int capacity, boolean fair) {
       if (capacity <= 0) throw new IllegalArgumentException();
       this.items = new Object[capacity];
       lock = new ReentrantLock(fair);
       notEmpty = lock.newCondition();
       notFull =  lock.newCondition();	
}
LinkedBlockingQueue

LinkedBlockingQueue 是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界队列。默认情况下元素采取自然顺序排列,也可以通过比较器 comparator 来指定元素的排序规则。元素按照升序排列。

SynchronousQueue

SynchronousQueue 是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。SynchronousQueue 可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景, 比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue 的吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue。

DelayQueue

DelayQueue 是一个支持延时获取元素的无界阻塞队列。队列使用 PriorityQueue 来实现。队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。我们可以将 DelayQueue 运用在以下应用场景:

  • 缓存系统的设计:可以用 DelayQueue 保存缓存元素的有效期,使用一个线程循环查询 DelayQueue,一旦能从 DelayQueue 中获取元素时,表示缓存有效期到了。
  • 定时任务调度。使用 DelayQueue 保存当天将会执行的任务和执行时间,一旦从 DelayQueue 中获取到任务就开始执行,从比如 TimerQueue 就是使用 DelayQueue 实现的。
LinkedTransferQueue

LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和 transfer 方法。

transfer 方法。如果当前有消费者正在等待接收元素(消费者使用 take() 方法或带时间限制的 poll() 方法时),transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的 tail 节点,并等到该元素被消费者消费了才返回。

阻塞队列的实现原理

如果队列是空的,消费者会一直等待,当生产者添加元素时候,消费者是如何知道当前队列有元素的呢?如果让你来设计阻塞队列你会如何设计,让生产者和消费者能够高效率的进行通讯呢?让我们先来看看 JDK 是如何实现的。

使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看 JDK 源码发现 ArrayBlockingQueue 使用了 Condition 来实现,代码如下:

private final Condition notFull;
private final Condition notEmpty;
	
public ArrayBlockingQueue(int capacity, boolean fair) {	
       
		// 省略其他代码
  	notEmpty = lock.newCondition();
  	notFull =  lock.newCondition();
  
}
	
 
public void put(E e) throws InterruptedException {

       checkNotNull(e);
       final ReentrantLock lock = this.lock;
       lock.lockInterruptibly();
       try {
           while (count == items.length)	notFull.await();
           insert(e);
       } finally {
           lock.unlock();	
       }
	
}
	
	
public E take() throws InterruptedException {
  
       final ReentrantLock lock = this.lock;
       lock.lockInterruptibly();
       	try {
        		while (count == 0)	notEmpty.await();
        		return extract();
 				} finally {
           lock.unlock();
       }
}
	
 
private void insert(E x) {	
       items[putIndex] = x;
       putIndex = inc(putIndex);	
       ++count;
       notEmpty.signal();
}

当我们往队列里插入一个元素时,如果队列不可用,阻塞生产者主要通过 LockSupport.park(this); 来实现


public final void await() throws InterruptedException {
           if (Thread.interrupted())
               throw new InterruptedException();
           Node node = addConditionWaiter();
           int savedState = fullyRelease(node);
           int interruptMode = 0;
           while (!isOnSyncQueue(node)) {
               LockSupport.park(this);
               if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                   break;
           }
           if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
               interruptMode = REINTERRUPT;
           if (node.nextWaiter != null) // clean up if cancelled
               unlinkCancelledWaiters();
           if (interruptMode != 0)

	reportInterruptAfterWait(interruptMode);
}

继续进入源码,发现调用 setBlocker 先保存下将要阻塞的线程,然后调用 unsafe.park 阻塞当前线程。


public static void park(Object blocker) {
       Thread t = Thread.currentThread();
       setBlocker(t, blocker);
       unsafe.park(false, 0L);
       setBlocker(t, null);
   }

非阻塞队列
ConcurrentLinkedQueue

ConcurrentLinkedQueue 是单向链表结构的无界并发队列。元素操作按照 FIFO (first-in-first-out 先入先出) 的顺序。适合“单生产,多消费”的场景。内存一致性遵循对ConcurrentLinkedQueue的插入操作先行发生于(happen-before)访问或移除操作。

它采用了CAS 算法+volatile来实现满足happen-before的访问或移除操作

特性

  • 在ConcurrentLinkedQueue head 并不一定代表真正的head,tail 不一定代表真正的tail,但是可以通过head 遍历到真正的head,通过tail可以遍历到真正的tail.

  • 同阻塞队列一样,ConcurrentLinkedQueue 入队的元素不能是空,当删除元素时,需要先设置元素节点Node的item域为null

head的不变性和可变性:
不变性:

  • 所有有效的节点都可以通过head节点遍历到,通过succ()方法
  • head不能为null
  • head节点的next不能指向自身

可变性:

  • head的item可能为null,也可能不为null
  • 允许tail滞后head,也就是说调用succc()方法,从head不可达tail

tail的不变性和可变性
不变性:

  • 队列的最后节点总是可以通过tail利用succ() 方法
  • tail不能为null

可变性:

  • tail的item可能为null,也可能不为null

  • tail节点的next域可以指向自身

  • 允许tail滞后head,也就是说调用succc()方法,从head不可达ta

JAVA 并发编程快速通关指南_第11张图片

具体的原理和源码分析可以看下面两篇参考文章:

https://cloud.tencent.com/developer/article/1803570

https://blog.csdn.net/u014634338/article/details/78825440

ConcurrentLinkedDeque

ConcurrentLinkedDeque 是双向链表结构的无界并发队列。与 ConcurrentLinkedQueue 的区别是该阻塞队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除)。适合“多生产,多消费”的场景。内存一致性遵循对 ConcurrentLinkedDeque 的插入操作先行发生于(happen-before)访问或移除操作

J.U.C工具类

CyclicBarrier

在这里插入图片描述

parties 是参与线程的个数,第二个构造方法有一个 Runnable 参数,这个参数表示最后一个到达线程要做的任务(回调方法)

public int await() throws InterruptedException, BrokenBarrierException

public int await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException

线程调用 await() 表示自己已经到达栅栏,BrokenBarrierException 表示栅栏已经被破坏,破坏的原因可能是其中一个线程 await() 时被中断或者超时

CountDownLatch

JAVA 并发编程快速通关指南_第12张图片

CountDownLatch 和 CyclicBarrier的区别

相比CountDownLatch,CyclicBarrier的本质区别是,CountDownLatch的参与线程线程在countDown()之后,可以继续执行自己的任务,而CyclicBarrier的参与线程,必须保持等待,直到栅栏打开后(参与线程await的数量达到目标),才会进行后续任务。

两者在使用中,调用await()的主体是不一样的,CountDownLatch.await()通常是在主(调用)线程调用的,而CyclicBarrier.await()是在参与的任务线程调用的,主线程不一定需要await(),这也是为什么CyclicBarrier提供了自动reset的功能。(其目的就是,不断的在调用线程,循环编排任务线程的启动和等待)

LockSupport

在没有LockSupport之前,线程的挂起和唤醒咱们都是通过Object的wait和notify/notifyAll方法实现

特性

①LockSupport不需要在同步代码块里 。所以线程间也不需要维护一个共享的同步对象了,实现了线程间的解耦。

②unpark函数可以先于park调用,所以不需要担心线程间的执行的先后顺序。

public class TestObjWait {

    public static void main(String[] args)throws Exception {
        Thread A = new Thread(new Runnable() {
            @Override
            public void run() {
                int sum = 0;
                for(int i=0;i<10;i++){
                    sum+=i;
                }
                LockSupport.park();
                System.out.println(sum);
            }
        });
        A.start();
        //睡眠一秒钟,保证线程A已经计算完成,阻塞在wait方法
        Thread.sleep(1000);
        LockSupport.unpark(A);
    }
}
ReentrantLock

jdk中独占锁的实现除了使用关键字synchronized外,还可以使用ReentrantLock。虽然在性能上ReentrantLock和synchronized没有什么区别,但ReentrantLock相synchronized而言功能更加丰富,使用起来更为灵活,也更适合复杂的并发场景。

特点
  • ReentrantLock可以实现公平锁
  • ReentrantLock可以响应中断
  • 可轮询tryLock(3000)
  • 结合Condition实现等待通知机制
  • 可重入
ReentrantLock最佳实践
公平锁
 Lock lock = new ReentrantLock(true);  
 ......  
 lock.tryLock(30000);  
 try {  
     // 对锁定对象进行更新等操作  
     //处理异常  
 } finally {  
     lock.unlock();  
 } 
可响应中断

在synchronied的代码中,进入临界区的代码是无法中断的,这个很不灵活,如果我们使用一个线程池来分发任务,如果一个代码长期占有锁肯定会影响到线程池的其他任务,因此,加入中断机制提高了对任务更强的控制性。

 public boolean sendOnSharedLine(String message) throws InterruptedException {   
     lock.lockInterruptibly();   
     try {   
         return cancellableSendOnSharedLine(message);   
     } finally {  
         lock.unlock();   
     }   
 }  
   
 private boolean cancellableSendOnSharedLine(String message) throws InterruptedException { ... } 

可轮询
	public boolean Polling(Account fromAcct, Account toAcct) {
    while (true) {
      if (fromAcct.lock.tryLock()) {
        try {
          if (toAcct.lock.tryLock()) {
            try { //业务逻辑 
            } finally {
              toAcct.lock.unlock();
            }
          }
        } finally {
          fromAcct.lock.unlock();
        }
      }
    }
    return false;
  }
结合Condition实现等待通知机制

condition可以用来替代Thread类的,wait 和 notify 方法,两者的区别是condition可以实现唤醒指定的线程

其底层运用的是Unsafe.park() 和 Unsafe.unpark()

public class BoundedBuffer {
  final Lock lock = new ReentrantLock();//锁对象
  final Condition notFull = lock.newCondition();//写线程条件
  final Condition notEmpty = lock.newCondition();//读线程条件
  final Object[] items = new Object[100];//缓存队列
  int putptr/*写索引*/, takeptr/*读索引*/, count/*队列中存在的数据个数*/;

  public void put(Object x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length)//如果队列满了
        notFull.await();//阻塞写线程
      items[putptr] = x;//赋值
      if (++putptr == items.length) putptr = 0;//如果写索引写到队列的最后一个位置了,那么置为0
      ++count;//个数++
      notEmpty.signal();//唤醒读线程
    } finally {
      lock.unlock();
    }
  }

  public Object take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0)//如果队列为空
        notEmpty.await();//阻塞读线程
      Object x = items[takeptr];//取值 
      if (++takeptr == items.length) takeptr = 0;//如果读索引读到队列的最后一个位置了,那么置为0
      --count;//个数--
      notFull.signal();//唤醒写线程
      return x;
    } finally {
      lock.unlock();
    }
  }

}
ReadWriteLock使用
public class Cache<K,V> {
    final Map<K,V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r =  rwl.readLock(); //读锁
    final Lock w = rwl.writeLock(); //写锁

    /**
     * 读缓存
     * @param key
     * @return
     */
    V get(K key){
        //获取读锁
        r.lock();
        try {
            return  m.get(key);
        }finally {
            r.unlock();
        }
    }
   /**
     * 写缓存
     * @param key
     * @param value
     * @return
     */
    V put(K key,V value){
         w.lock();
         try{
             return m.put(key,value);
         }finally {
             w.unlock();
         }
    }
}

StampedLock

由于ReentrantLock的读写存在,在读多写少的场景下,会造成写饥饿的问题,jdk8引入了StampedLock

该类是一个读写锁的改进,它的思想是读写锁中读不仅不阻塞读,同时也不应该阻塞写:在读的时候如果发生了写,则应当重读而不是在读的时候直接阻塞写!

因为在读线程非常多而写线程比较少的情况下,写线程可能发生饥饿现象,也就是因为大量的读线程存在并且读线程都阻塞写线程,因此写线程可能几乎很少被调度成功!当读执行的时候另一个线程执行了写,则读线程发现数据不一致则执行重读即可。

所以读写都存在的情况下,使用StampedLock就可以实现一种无障碍操作,即读写之间不会阻塞对方,但是写和写之间还是阻塞的!

StampedLock锁提供了三种模式的读写控制:

写锁writeLock

是个排它锁或者叫独占锁,同时只有一个线程可以获取该锁,当一个线程获取该锁后,其它请求的线程必须等待,当目前没有线程持有读锁或者写锁的时候才可以获取到该锁,请求该锁成功后会返回一个stamp票据变量用来表示该锁的版本,当释放该锁时候需要unlockWrite并传递参数stamp。

悲观读锁readLock

是个共享锁,在没有线程获取独占写锁的情况下,同时多个线程可以获取该锁,如果已经有线程持有写锁,其他线程请求获取该读锁会被阻塞。这里说的悲观是说在具体操作数据前悲观的认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑,请求该锁成功后会返回一个stamp票据变量用来表示该锁的版本,当释放该锁时候需要unlockRead并传递参数stamp。

乐观读锁tryOptimisticRead

是相对于悲观锁来说的,在操作数据前并没有通过CAS设置锁的状态,如果当前没有线程持有写锁,则简单的返回一个非0的stamp版本信息,获取该stamp后在具体操作数据前还需要调用validate验证下该stamp是否已经不可用,也就是看当调用tryOptimisticRead返回stamp后到到当前时间间是否有其他线程持有了写锁,如果是那么validate会返回0,否者就可以使用该stamp版本的锁对数据进行操作。由于tryOptimisticRead并没有使用CAS设置锁状态所以不需要显示的释放该锁。该锁的一个特点是适用于读多写少的场景,因为获取读锁只是使用与或操作进行检验,不涉及CAS操作,所以效率会高很多,但是同时由于没有使用真正的锁,在保证数据一致性上需要拷贝一份要操作的变量到方法栈,并且在操作数据时候可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。

StampedLock的实现思想

StampedLockd的内部实现是基于CLH锁的,CLH锁原理:锁维护着一个等待线程队列,所有申请锁且失败的线程都记录在队列。一个节点代表一个线程,保存着一个标记位locked,用以判断当前线程是否已经释放锁。当一个线程试图获取锁时,从队列尾节点作为前序节点, 使用类似如下代码(一个空的死循环)判断前序节点是否已经成功的释放了锁:

while(pred.locked){  }

pred表示当前试图获取锁的线程的前序节点,如果前序节点没有释放锁,则当前线程就执行该空循环并不断判断前序节点的锁释放,即类似一个自旋锁的效果,避免被系统挂起。当循环一定次数后,前序节点还没有释放锁,则当前线程就被挂起而不再自旋,因为空的死循环执行太多次比挂起更消耗资源。

参考资料:

https://duanguangguang.github.io/2018/12/31/concurrent/stampedlock/

AQS( AbstractQueuedSynchronizer )

在J2SE 1.5的java.util.concurrent包(下称j.u.c包)中,大部分的同步器(例如锁,屏障等等)都是基AbstractQueuedSynchronizer(下称AQS类)这个简单的框架来构建的。这个框架为同步状态的原子性管理、线程的阻塞和解除阻塞以及排队提供了一种通用机制
JAVA 并发编程快速通关指南_第13张图片

AQS中的int类型的state值,这里就是通过CAS(乐观锁)去修改state的值。lock的基本操作还是通过乐观锁来实现的。获取锁通过CAS,没有获取到锁,通过自旋CAS等待获取锁
关键方法:

  • tryAcquire:会尝试再次通过CAS获取一次锁。
  • addWaiter:通过自旋CAS,将当前线程加入上面锁的双向链表(等待队列)中。
  • acquireQueued:通过自旋,判断当前队列节点是否可以获取锁。
当前节点在队列中的状态

JAVA 并发编程快速通关指南_第14张图片

独占模式和共享模式

JAVA 并发编程快速通关指南_第15张图片

JAVA 并发编程快速通关指南_第16张图片

参考文章:

https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

AQS应用举例
ReentrantLock 重入互斥锁

AQS 结构

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    static final class Node {
        volatile int waitStatus; // 节点状态
        volatile Node prev; // 前置指针
        volatile Node next; // 后继指针
        volatile Thread thread; // 当前线程
    }
    private transient volatile Node head; // 队列头指针
    private transient volatile Node tail; // 队列未指针
    // =0:无锁状态
    // >0:已有线程获取锁,state 数值表示重入次数
    private volatile int state; 
}
加锁过程

JAVA 并发编程快速通关指南_第17张图片
JAVA 并发编程快速通关指南_第18张图片
JAVA 并发编程快速通关指南_第19张图片

解锁过程

JAVA 并发编程快速通关指南_第20张图片
JAVA 并发编程快速通关指南_第21张图片

线程池

为什么建议使用自定义的线程池
  • 等待采用的是无界队列,存在内存溢出的风险!
  • 没有实现异常处理类,会导致异常丢失的问题
  • 可以给线程自定义名称,方便问题定位
线程池参数调优
核心线程数怎么设置
场景分析

有一个异步编排任务,在Controller层的某个方法内,主调用线程会通过线程池,启动两个子任务线程,启动后,调用线程阻塞,等待任务线程都完成后,继续后续的逻辑。

JAVA 并发编程快速通关指南_第22张图片

线程池的核心参数如下图所示:

JAVA 并发编程快速通关指南_第23张图片

请问等待队列设置为10000,还是设置成0 后的吞吐量高

ForkJoinPool

Fork/Join 框架是一种在 JDK 7 引入的线程池,用于并行执行把一个大任务拆成多个小任务并行执行,最终汇总每个小任务结果得到大任务结果的特殊任务。

通过其命名也很容易看出框架主要分为 Fork 和 Join 两个阶段,第一阶段 Fork 是把一个大任务拆分为多个子任务并行的执行,第二阶段 Join 是合并这些子任务的所有执行结果,最后得到大任务的结果。

不难发现其执行主要流程:首先判断一个任务是否足够小,如果任务足够小,则直接计算,否则,就拆分成几个更小的小任务分别计算,这个过程可以反复的拆分成一系列小任务。

Fork/Join 框架是一种基于分治的算法,通过拆分大任务成多个独立的小任务,然后并行执行这些小任务,最后合并小任务的结果得到大任务的最终结果,通过并行计算以提高效率。

使用示例

通常情况下,我们并不需要直接继承这个 ForkJoinTask 类,而是使用框架提供的两个 ForkJoinTask 的子类:

  • RecursiveAction 用于表示没有返回结果的 Fork/Join 任务。
  • RecursiveTask 用于表示有返回结果的 Fork/Join 任务
public class SumTask extends RecursiveTask<Long> {
  	...
    protected Long compute() {
    if (任务足够小?) {
        return computeDirect();
    }
    // 任务太大,一分为二:
    SumTask subtask1 = new SumTask(...);
    SumTask subtask2 = new SumTask(...);
    // 分别对子任务调用fork():
     invokeAll(subtask1, subtask2);
    // 合并结果:
    Long subresult1 = subtask1.join();
    Long subresult2 = subtask2.join();
    return subresult1 + subresult2;
	}
}

工作窃取算法

JAVA 并发编程快速通关指南_第24张图片

你可能感兴趣的:(JAVA,高并发,java,高并发)