JAVAEE初阶第三节——多线程进阶

系列文章目录

JAVAEE初阶第三节------多线程进阶

文章目录

  • 系列文章目录
  • [一. 常见的锁策略](#一. 常见的锁策略)
  • [二. synchronized的优化手段](#二. synchronized的优化手段)
  • 三.CAS
    • 1.概念
    • 2.CAS的应用
      • [2.1 实现原子类](#2.1 实现原子类)
      • [2.2 实现自旋锁](#2.2 实现自旋锁)
    • [3. CAS的ABA问题](#3. CAS的ABA问题)
      • [3.1 概念](#3.1 概念)
      • [3.2 ABA问题引来的BUG](#3.2 ABA问题引来的BUG)
      • [3.3 ABA问题的解决方法](#3.3 ABA问题的解决方法)
  • [四.Callable 接口](#四.Callable 接口)
    • [1.Callable 的用法入](#1.Callable 的用法入)
  • [五.JUC(java.util.concurrent) 的常见类](#五.JUC(java.util.concurrent) 的常见类)
    • 1.ReentrantLock
    • [2. 原子类](#2. 原子类)
    • [3. 信号量 Semaphore](#3. 信号量 Semaphore)
    • [4. CountDownLatch](#4. CountDownLatch)
    • 5.线程池
  • 六.线程安全的集合类
    • [1. 多线程环境使用 ArrayList](#1. 多线程环境使用 ArrayList)
    • [2. 多线程环境使用队列](#2. 多线程环境使用队列)
    • [3. 多线程环境使用哈希表](#3. 多线程环境使用哈希表)
  • 七.死锁

多线程进阶

  1. 常见的锁策略
  2. synchronized的优化手段
  3. CAS
  4. Callable 接口
  5. JUC(java.util.concurrent) 的常见类
  6. 线程安全的集合类
  7. 死锁

一. 常见的锁策略

1.乐观锁和悲观锁

这是两种不同的锁的实现方式:
乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候就不会做太多的工作。加锁过程做的事情比较少,加锁的速度可能就更快,但是更容易引入一些其他的问题(但是可能会消耗更多的CPU资源)

悲观锁,在加锁之前,预估当前锁冲突出现的概率比较大,因此加锁的时候,就会做更多的工作。做的事情更多,加锁的速度可能更慢,但是整个过程中不容易出现其他问题。

2. 轻量级锁和重量级锁

轻量级锁,加锁的开销小,加锁的速度更快。=>轻量级锁,一般就是乐观锁。

重量级锁,加锁的开销更大,加锁速度更慢。=>重量级锁,一般也就是悲观锁。
轻量重量,加锁之后,对结果的评价,而悲观乐观,是加锁之前,对未发生的事情进行的预估。

整体来说,这两种角度,描述的是同一个事情。

3.自旋锁和挂起等待锁

自旋锁就是轻量级锁的一种典型实现。

自旋锁进行加锁的时候搭配一个while循环。如果加锁成功,自然循环结束。如果加锁不成功,不是阻塞放弃cpu,而是进行下一次循环,再次尝试获取到锁。这个反复快速执行的过程,就称为自旋。</mark(一旦其他线程释放了锁,就能第一时间拿到锁)

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).
    synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
    挂起等待锁就是重量级锁的一种典型实现,同时也是一种悲观锁.

重量级锁的体现:进行挂起等待的时候,就需要内核调度器介入。这一块要完成的操作就多了。真正获取到锁要花的时间也就更多一些了。

悲观锁:这个锁可以适用于锁冲突激烈的情况

线程一旦进入阻塞,就需要重新参与系统的调度,啥时候能够调度上CPU就是未知数了。但是好处就是这个阻塞的过程中把CPU资源让出来,用来做点别的事情了.

4. 普通互斥锁和读写锁

普通互斥锁类似于synchronized操作涉及到加锁和解锁。
这里的读写锁,把加锁分成两种情况:

(1)加读锁,一个线程加读锁的时候另一个线程,只能读,不能写。

(2)加写锁,一个线程加写锁的时候,另一个线程,不能写,也不能读。

读锁和读锁之间,不会出现锁冲突(不会阻塞)

写锁和写锁之间,会出现锁冲突(会阻塞)

读锁和写锁之间,会出现锁冲突(会阻塞)
引入读写锁的原因:

如果两个线程读,本身就是线程安全的!不需要进行互斥!如果使用synchronized这种方式加锁,两个线程读,也会产生互斥,产生阻塞。(对于性能有一定的损失)完全给读操作不加锁,也不行,就怕一个线程读一个线程写.可能会读到写了一半的数据.读写锁,就可以很好的解决上述问题.(读写锁就可以节省这些并发读之间锁冲突的开销,这就对于性能提升很明显了。)

5. 公平锁和非公平锁

公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁。

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程的先后顺序(使用公平锁,天然就可以避免线程饿死的问题)

非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁。

synchronized 是非公平锁

6.可重入锁和不可重入锁

一个线程针对这一把锁,连续加锁两次,不会死锁,就是可重入锁。会死锁,就是不可重入锁。

synchronized是可重入锁。系统自带的锁,是不可重入的锁(可重入锁中需要记录持有锁的线程是谁,加锁的次数的计数)

二. synchronized的优化手段

1.synchronized的锁升级

当线程执行到synchronized的时候,如果这个对象当前处于未加锁的状态,就会经历以下过程:

  1. 偏向锁阶段

偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.

如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。

但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

  1. 轻量级锁阶段

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态.(自适应的自旋锁)于此同时,synchronized内部也会统计当前这个锁对象上,有多少个线程在参与竞争。当发现参与竞争的线程比较多了,就会进一步升级为重量级锁。

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源. 因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 "自适应"。
优势:另外的线程把锁释放了,就会第一时间拿到锁。

劣势:比较消耗CPU。

  1. 重量级锁阶段

此时拿不到锁的线程就不会继续自旋了,而是进入"阻塞等待',就会让出cpu了。(不会使cpu占用率太高)等到当前线程释放锁的时候,就由系统随机唤醒一个线程来获取锁了。

此处锁只能升级,不能降级。自适应这个词,严格的说不算很严谨

2. synchronized的其他的优化操作

  1. 锁消除

锁消除也是synchronized中内置的优化策略,是编译器优化的一种方式。编译器编译代码的时候,如果发现这个代码,不需要加锁,就会自动把锁给消除掉。

这里的优化是比较保守的。比如,就只有一个线程,在这一个线程里,加锁了。或者说加锁代码中,没有涉及到"成员变量的修改",就只是一些局部变量都是不需要加锁的和其他的很多"模棱两可"的线程,编译器也不知道这里是要加还是不加,都不会去消除。

锁消除,针对一眼看上去就完全不涉及线程安全问题的代码,能够把锁消除掉。

  1. 锁粗化

锁的粗化会把多个细粒度的锁,合并成一个粗粒度的锁。

synchronized { } 大括号里包含的代码越少,就认为锁的粒度越细。包含的代码越多,就认为锁的粒度越粗。(通常情况下,是更偏好于让锁的粒度细一些,更有利于多个线程并发执行的,但是有的时候,是希望锁的粒度粗点也挺好)

三.CAS

1.概念

CAS: 全称Compare and swap,是一个CPU的指令。完成的任务就是:"比较并交换",一个 CAS 涉及到以下操作:

假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。
    CAS 伪代码

下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.

java 复制代码
boolean CAS(address, expectValue, swapValue) {
	if (&address == expectedValue) {
		&address = swapValue;
		return true;
	}
	return false;
}

比较address内存地址中的值和expected寄存器中的的值判断它们的值是否相同。

如果相同,就把swap寄存器的值和address内存中的值,进行交换,返回true.(说是"交换",也可以理解成"赋值".因为往往只关注内存中最终的值。因此在伪代码中体现的结果也是"赋值"而不是交换。)

如果不相同,则啥都不干,无事发生,返回false。
实际上一条CPU就能完成上面操作
基于CAS指令,编写线程安全的代码又有了一种新的方法。(当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。)之前线程安全都是靠加锁,加锁=>阻塞=>性能降低。

使用CAS,不涉及加锁,不会阻塞。合理使用也能保证线程安全。(无锁编程)

CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

2.CAS的应用

CAS本身是CPU指令,操作系统对指令进行了封装。而jvm又对操作系统提供的api又封装了一层.

JAVA中CAS的api放到了unsafe包里,这样的操作,涉及到一些系统底层的内容,使用不当的话可能会带来一些风险。一般不建议直接使用CAS

JAVA的标准库,对于CAS又进行了进一步的封装,提供了一些工具类,让咱们直接使用。其中最主要的一个工具,叫做"原子类"。

2.1 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作(没有加锁也能保证线程安全).
用"原子类"解决自增到12W的线程安全问题

java 复制代码
public static AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
	Thread thread1 = new Thread(()->{
		for (int i = 0; i < 60000; i++) {
			count.getAndIncrement();
		}
	});
	Thread thread2 = new Thread(()->{
		for (int i = 0; i < 60000; i++) {
			count.getAndIncrement();
		}
	});

	thread1.start();
	thread2.start();

	thread1.join();
	thread2.join();

	System.out.println("count = " + count);
}

之前的count++是3个指令,不是原子的操作。所以用synchronized将这三个指令打包成一个"原子"的就能解决线程安全问题。而count.getAndIncrement()这个操作本身就只有一个指令,是原子的。就能直接解决线程安全问题。

伪代码实现:

java 复制代码
class AtomicInteger {
	private int value;//实际上就是AtomicInteger类型的变量
	public int getAndIncrement() {
		int oldValue = value;//这个值就是初始化成Atomiclnteger里面保存的整数值value
		while (CAS(value, oldValue, oldValue + 1) != true) {
			oldValue = value;
		}
		return oldValue;
	}
}

如果发现比较交换成功,循环就结束了。此时value已经被更新成value+1了。

如果没成功,就再来一次循环,直到成功为止.
多线程中的执行过程:

之前的线程不安全,是因为内存变了但是寄存器中的值没有跟着变,所以接下来的修改就出错了。

使用CAS这种方式,就能确保识别出内存的值是不是变了。不变,才会进行修改(VaLue没有被修改)

如果Value变了,重新读取内存的值。确保是基于内存中的最新的值再进行修改,这样就非常巧妙的把之前的线程安全问题就解决了。

2.2 实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码

java 复制代码
public class SpinLock {
	private Thread owner = null;
	public void lock() {
		// 通过 CAS 看当前锁是否被某个线程持有.
		// 如果这个锁已经被别的线程持有, 那么就自旋等待.
		// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
		while (!CAS(this.owner, null, Thread.currentThread())) {
		}
	}
	public void unlock() {
		this.owner = null;
	}
}

当owner不为null的时候,循环就会一直执行下去。通过这样的"忙等"来完成等待效果

此处自旋式的等,没有放弃cpu,不会参与到调度,也就没有调度开销了。

但是他的缺点就是要消耗更多的CPU资源.

3. CAS的ABA问题

3.1 概念

CAS在使用的时候,关键要点,是要判定当前内存的值是否是和寄存器中的值一样的.是一样的,就进行修改,不一样就啥都不做.这个本质上是判定,当前这个代码执行过程中,是否有其他线程穿插进来执行了。

CAS的ABA问题就是在这个基础上的一种的情况。

假设存在两个线程 thread1 和 thread2. 有一个共享变量 num, 初始值为 0.

在执行CAS之前,另一个线程把num从0变成了100,然后又从100变成了0。

一般来说,即使出现上述情况,也问题不大,不会产生啥BUG但是一些极端场景下就会出现BUG

3.2 ABA问题引来的BUG

假设有 100 块存款. 想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行-50操作.

此时期望一个线程执行 -50 成功, 另一个线程 -50 失败.

如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.

  • 正常情况:
  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50.此时线程2还在阻塞等待中.
  3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败
  • 异常的过程:
  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 线程3又存入了 50, 账户余额变成 100
  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
    这个时候, 扣款操作被执行了两次!!都是 ABA 问题引起的问题。

3.3 ABA问题的解决方法

解决方法:

(1)约定数据变化是单向的(只能增加或者只能减少),不能是双向的(又能增加又能减少)。

(2)对于本身就必须双向变化的数据,可以给它引入一个版本号。版本号这个数字就是只能增加,不能减少的.(主要方法)
对比理解上面的转账例子

假设有 100 块存款. 想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行-50操作.

此时期望一个线程执行 -50 成功, 另一个线程 -50 失败.

为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.

  1. 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100, 版本号为 1, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 线程3又存入了 50, 账户余额变成 100, 版本号变成3.
  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败.
    在 Java 标准库中提供了 AtomicStampedReference< E > 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能

四.Callable 接口

( 1 )继承Thread(包含了匿名内部类的方式)

( 2 )实现Runnable(包含了匿名内部类的方式)

( 3 )基于lambda

( 4 ) 基于Callable

( 5 )基于线程池
Runnable关注的是这个过程,不关注执行结果。Runnable提供的run方法,返回值类型是void。

Callable要关注执行结果。Callable提供的call方法,返回值就是线程执行任务得到的结果.

1.Callable 的用法入

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000

普通方法:

java 复制代码
private static int sum = 0;
private static  Object lock = new Object();

public static void main(String[] args) {
	Thread thread = new Thread(new Runnable(){
		@Override
		public void run() {
			synchronized(lock) {
				int result = 0;
				for (int i = 1; i <= 1000; i++) {
					result += i;
				}
				sum = result;
				lock.notify();
			}
		}
		});
	thread.start();

	synchronized(lock) {
		try {
			lock.wait();
		}
		catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	System.out.println("sum = " + sum);
}

可以看到, 上述代码需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
使用Callable更好的解决这个问题

  • 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  • 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  • 把 callable 实例使用 FutureTask 包装一下.
  • 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  • 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果
java 复制代码
public static void main(String[] args) throws ExecutionException, InterruptedException{
	Callable<Integer> callable = new Callable<Integer>() {
		@Override
		public Integer call() throws Exception {
			int result = 0;
			for (int i = 1; i <= 1000; i++) {
				result += i;
			}
			return result;
		}
	};

	FutureTask<Integer> futureTask = new FutureTask<>(callable);
	Thread t = new Thread(futureTask);
	t.start();

	// 接下来这个代码也不需要 join, 使用 futureTask 获取到结果.
	System.out.println(futureTask.get());
}

五.JUC(java.util.concurrent) 的常见类

1.ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入"
ReentrantLock 的用法:

lock(): 加锁, 如果获取不到锁就死等.

trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.

unlock(): 解锁
使用ReentrantLock最好把unlock操作放到finally中。

java 复制代码
ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
	//操作代码
}
finally {
	lock.unlock()
}

ReentrantLock 和 synchronized 的区别:

(1)synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).

(2)synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.

(3)synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.

(4)synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式

java 复制代码
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
	sync = fair ? new FairSync() : new NonfairSync();
}

(5)更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

2. 原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

AtomicBoolean

AtomicInteger

AtomicIntegerArray

AtomicLong

AtomicReference

AtomicStampedReference
以 AtomicInteger 举例,常见方法有:

addAndGet(int delta); i += delta;

decrementAndGet(); --i;

getAndDecrement(); i--;

incrementAndGet(); ++i;

getAndIncrement(); i++;

3. 信号量 Semaphore

信号量, 用来表示 "可用资源的个数". 本质上就是一个理解信号量

可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.

当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)

当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)

如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

代码示例

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
java 复制代码
public static void main(String[] args) {
	Semaphore semaphore = new Semaphore(4);
	Runnable runnable = new Runnable(){
		@Override
		public void run() {
			try {
				System.out.println("申请资源");
				semaphore.acquire();
				System.out.println("我获取到资源了");
				Thread.sleep(1000);
				System.out.println("我释放资源了");
				semaphore.release();
			}
catch (InterruptedException e) {
 e.printStackTrace();
}
}
	};
	for (int i = 0; i < 20; i++) {
		Thread t = new Thread(runnable);
		t.start();
	}
}

4. CountDownLatch

同时等待 N 个任务执行结束.

比如,多线程下载这样的场景,最终执行完成之后,要把所有内容拼到一起。这个拼接必然要等到所有线程执行完成。这时候使用CountDownLatch就可以很方便完成这个操作。

如果使用join方式,就只能使用每个线程执行一个任务。借助countDownLatch就可以让一个线程能执行多个任务。
代码示例:

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
java 复制代码
public static void main(String[] args)  throws Exception {
	CountDownLatch latch = new CountDownLatch(10);
	Runnable r = new Runnable(){
		@Override
		public void run() {
			try {
				Thread.sleep((long)(Math.random() * 100));
				latch.countDown();
			}
catch (Exception e) {
 e.printStackTrace();
}
}
	};
	for (int i = 0; i < 10; i++) {
		new Thread(r).start();
	}
	// 必须等到 10 人全部回来
	latch.await();
	System.out.println("比赛结束");
}

5.线程池

详情可以看多线程基础(下)中的线程池部分

六.线程安全的集合类

原来的集合类, 大部分都不是线程安全的.

Vector, Stack, HashTable, 是线程安全的(关键方法加上了synchronized)(不建议用), 其他的集合类不是线程安全的

1. 多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
  2. Collections.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.

ArrayList 的关键操作上都带有 synchronized。

  1. 使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。

当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

(1) 占用内存较多.

(2)新写的数据不能被第一时间读取到

2. 多线程环境使用队列

  1. ArrayBlockingQueue - 基于数组实现的阻塞队列

  2. LinkedBlockingQueue - 基于链表实现的阻塞队列

  3. PriorityBlockingQueue - 基于堆实现的带优先级的阻塞队列

  4. TransferQueue - 最多只包含一个元素的阻塞队列

3. 多线程环境使用哈希表

HashMap 本身不是线程安全的.

在多线程环境下使用哈希表可以使用:

Hashtable

ConcurrentHashMap

  1. Hashtable

只是简单的把关键方法加上了 synchronized 关键字.

java 复制代码
public synchronized V put(K key,V value)
public synchronized V get(object key)

这相当于直接针对 Hashtable 对象本身加锁.

如果多线程访问同一个 Hashtable 就会直接造成锁冲突.

size 属性也是通过 synchronized 来控制同步, 也是比较慢的.

一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低

  1. ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例
(1) 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是用 synchronized, 但不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.

(2)充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新,减少一些加锁. 避免出现重量级锁的情况. (针对哈希表元素个数的维护)

(3)优化了扩容方式: 化整为零 。

  • 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
  • 扩容期间, 新老数组同时存在.
  • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
  • 搬完最后一个元素再把老数组删掉.
  • 这个期间, 插入只往新数组加.
  • 这个期间, 查找需要同时查新数组和老数组

七.死锁

死锁问题具体可以看多线程基础(中)介绍的死锁问题

相关推荐
dj24429457073 分钟前
JAVA中的Lamda表达式
java·开发语言
心怀梦想的咸鱼11 分钟前
UE5 第一人称射击项目学习(四)
学习·ue5
AI完全体15 分钟前
【AI日记】24.11.22 学习谷歌数据分析初级课程-第2/3课
学习·数据分析
工业3D_大熊16 分钟前
3D可视化引擎HOOPS Luminate场景图详解:形状的创建、销毁与管理
java·c++·3d·docker·c#·制造·数据可视化
szc176720 分钟前
docker 相关命令
java·docker·jenkins
程序媛-徐师姐30 分钟前
Java 基于SpringBoot+vue框架的老年医疗保健网站
java·vue.js·spring boot·老年医疗保健·老年 医疗保健
yngsqq30 分钟前
c#使用高版本8.0步骤
java·前端·c#
流星白龙33 分钟前
【C++习题】10.反转字符串中的单词 lll
开发语言·c++
尘浮生40 分钟前
Java项目实战II基于微信小程序的校运会管理系统(开发文档+数据库+源码)
java·开发语言·数据库·微信小程序·小程序·maven·intellij-idea
MessiGo41 分钟前
Python 爬虫 (1)基础 | 基础操作
开发语言·python