JUC的常见类、多线程环境使用集合类
目录
JUC的常见类
原子类
线程池
信号量
CountDownLatch
线程安全的集合类
多线程环境使用 ArrayList
多线程环境使用队列
多线程环境使用哈希表
总结:
JUC的常见类
Callable 接口、ReentrantLock:见上节
原子类
原子类内部用的是CAS实现,所以性能要比加锁实现i++高很多。原子类有以下几个
- AtomicBoolean
- Atomiclnteger
- AtomiclntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
其中 Atomiclnteger 的常见方法有:
addAndGet(int delta); 相当于 i += delta;
decrementAndGet(); 相当于 --i;
getAndDecrement(); 相当于 i--;
incrementAndGet(); 相当于 ++i;
getAndIncrement(); 相当于 i++;
Atomiclnteger / AtomicLong 应用场景:
1. 进行数据的统计
2. 统计服务器收到的请求数量
……
线程池
虽然创建销毁线程比创建销毁进程更轻量,但是频繁创建销毁线程的时候还是会比较低效。线程池就是为了解决这个问题
如果某个线程不再使用了,并不是真正把线程释放,而是放到一个 “池子” 中,下次如果需要用到线程就直接从池子中取,不必通过系统重新创建了
ExecutorService 和 Executors
代码示例:
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello");}
});
ExecutorService 表示一个线程池实例
Executors 是一个工厂类,能够创建出几种不同风格的线程池
ExecutorService 的 submit 方法能够向线程池中提交若干个任务
Executors 创建线程池的几种方式:见前面几节
ThreadPoolExecutor
四种构造方法,7个参数:见前面几节
信号量
信号量(Semaphore[ˈseməfɔː(r)]),用来表示 “可用资源的个数",本质上就是一个计数器
申请一个资源,计数器就会 -1 -> P操作(acquire)
释放一个资源,计数器就会 +1 -> V操作(release)
计数器为 0 时,继续申请,就会阻塞等待,直到有其他线程释放资源
public class Demo43 {public static void main(String[] args) throws InterruptedException {// 指定可用资源的个数是 "3"Semaphore semaphore = new Semaphore(3);semaphore.acquire();System.out.println("进行一次 P 操作");semaphore.acquire();System.out.println("进行一次 P 操作");semaphore.acquire();System.out.println("进行一次 P 操作");semaphore.acquire(); // 代码走到这里会阻塞System.out.println("进行一次 P 操作");}
}
信号量的一个特殊情况:初始值为1的信号量
取值要么是1要么是0(二元信号量)等价于 “锁”
普通的信号量,就相当于锁的更广泛的推广,普通的初始值为 N 的信号量,可以限制同时有多少个线程来执行某个逻辑
使用二元信号量的方式对 count++ 加锁:
public class Demo44 {private static int count = 0;public static void main(String[] args) {Semaphore semaphore = new Semaphore(1);Thread t1 = new Thread(new Runnable() {public void run() {for (int i = 0; i < 50000; i++) {try {semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {throw new RuntimeException(e);}}}});Thread t2 = new Thread(new Runnable() {public void run() {for (int i = 0; i < 50000; i++) {try {semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {throw new RuntimeException(e);}}}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Final count: " + count);}
}
CountDownLatch
经常把一个大的任务拆分成多个子任务,使用多线程执行这些子任务,从而提高程序的效率
如何衡量,这多个子任务都完成了呢?(整个任务都完成了呢?)
可以使用 CountDownLatch 同时等待 N 个任务执行结束
使用过程:
1)构造 CountDownLatch 实例,假设构造方法中指定的参数是10,就表示有10个任务
2)每个任务执行完毕之后,都调用一次 latch.countDown 方法,在 CountDownLatch 内部的计数器会同时自减,当一共调用了10次 countDown(CountDownLatch 计数器自减到0),说明任务全都完成了
3)主线程中调用 latch.await 方法,等待所有任务执行完毕,当调用 countDown 的次数达到设置的任务数时,await 就会返回,否则会阻塞等待
public class Demo45 {public static void main(String[] args) throws InterruptedException {// 现在把整个任务拆成 10 个部分. 每个部分视为是一个 "子任务".// 可以把这 10 个子任务丢到线程池中, 让线程池执行.// 当然也可以安排 10 个独立的线程执行.// 构造方法中传入的 10 表示任务的个数.CountDownLatch latch = new CountDownLatch(10);ExecutorService executor = Executors.newFixedThreadPool(4);for (int i = 0; i < 10; i++) {int id = i;executor.submit(() -> {System.out.println("子任务开始执行: " + id);try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("子任务结束执行: " + id);latch.countDown();});}// 这个方法阻塞等待所有的任务结束// await 的 a 表示 alllatch.await();System.out.println("所有任务执行完毕");executor.shutdown();}
}
线程安全的集合类
Vector,Stack,HashTable 是线程安全的(不建议用),其他的集合类不是线程安全的
多线程环境使用 ArrayList
1. 自己使用同步机制(synchronized 或 ReentrantLock),分析清楚,要把哪些代码打包到一起,成为一个“原子“操作 [推荐]
2. Collections.synchronizedList(new ArrayList):使用 synchronizedList 方法套壳,返回的新的 List 的各种关键方法都是带有 synchronized 的,类似于 Vector / Hashtable / StringBuffer [不推荐]
3. 使用 CopyOnWriteArrayList
CopyOnWrite:编程中的一种常见思想方法(读写分离的思想),即写时拷贝
- 当在数组中添加元素的时候,不直接在当前数组添加,而是先对当前数组进行 Copy,复制出一个新的数组,然后在新的数组里添加元素
- 添加完元素之后,再将原数组的引用指向新的数组
详细说明:
1. 复制过程中,如果其他线程在读,就直接读取旧版本的数据,虽然复制过程不是原子的(会消耗一定的时间),但由于提供了旧版本的数据,所以不影响其他线程读取
2. 新数组复制完毕之后,直接进行引用的修改(引用的赋值是 “原子” 的),后续的读操作就都是针对新数组了
3. 这样就可以确保在读取过程中,要么读到的是旧版数据,要么读到的是新版数据,不会读到 “修改一半” 的数据(读到新版和旧版数据都是正确的操作)
4. 这样做的好处是可以对数组进行并发的读,而不需要加锁,不会产生阻塞
优点:
- 在读多写少的场景下性能很高,不需要加锁竞争
缺点:
- 数组非常大时,拷贝一份非常大的数据,非常低效
- 占用的内存较多
- 多个线程同时修改,可能出现数据丢失的问题(原数组的引用只指向最新修改的数组)
- 新写的数据不能被第一时间读取到
所以上述过程只适合特定场景,比如服务器进行重新加载配置的时候
1. 服务器正在运行,但是需要修改配置,正常来说,修改配置文件之后,是不会立即生效的,需要重启服务器后生效,但是重启的过程中服务器不能工作,这样是不可以的
2. 所以很多服务器会提供配置重加载(reload),这个功能可以在服务器运行过程中让修改后的配置立即生效
重加载:
配置会被读取到服务器的内存中,以数组/哈希存储,服务器代码中的其他逻辑会读取这些数组/哈希中的值。当程序员手动修改配置文件之后,手动触发 reload 功能,服务器就会创建新的数组/哈希,加载新的配置,加载完毕之后,使用新配置,代替旧配置。这就是写时拷贝的做法
多线程环境使用队列
1. ArrayBlockingQueue:基于数组实现的阻塞队列
2. LinkedBlockingQueue:基于链表实现的阻塞队列
3. PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
4. TransferQueue:最多只包含一个元素的阻塞队列
多线程环境使用哈希表
1. HashMap:线程不安全
2. Hashtable:线程安全
只是简单的把关键方法加上了 synchronized 关键字,是直接针对 Hashtable 对象本身(this)加锁的
一个 Hashtable 只有一把锁,两个线程访问 Hashtable 中的任意数据都会出现锁竞争
- 如果多线程访问同一个Hashtable 就会直接造成锁冲突
- size 属性也是通过 synchronized 来控制同步的,效率很低
- 一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,需要进行长时间的加锁,效率非常低
3. ConcurrentHashMap:效率更高。是按照桶级别进行加锁,而不是给整个哈希加一个全局锁,可以有效降低锁冲突的概率
是对 Hashtable 做出的一系列改进和优化
- 读操作没有加锁(但是使用了volatile保证从内存读取结果),只对写操作进行加锁。加锁的方式仍然是是用 synchronized,但是不是锁整个对象,而是 "锁桶"(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率
- size 属性通过原子类(CAS)更新的,避免出现重量级锁的情况
- 优化了扩容方式:化整为零。
- 发现需要扩容的线程,会创建一个新的数组,同时只搬一小部分元素
- 扩容期间,新老数组同时存在
- 后续每个操作 ConcurrentHashMap 的线程,都会参与搬运的过程,每个操作都负责搬运一小部分元素
- 这个期间,插入只往新数组插,查找需要同时查新数组和老数组
- 搬完最后一个元素再把老数组删掉
一口气进行所有的搬运比较耗时,ConcurrentHashMap 的扩容会把整个的搬运拆成多次来完成
总结:
1.ConcurrentHashMap 的读是否要加锁,为什么?
答:读操作没有加锁,目的是为了进一步降低锁冲突的概率。为了保证读到最新修改的数据,搭配了 volatile 关键字
2. 介绍下 ConcurrentHashMap 的锁分段技术?
答:这个是Java1.7中采取的技术,Java1.8中已经不再使用了,简单的说就是把若干个哈希桶分成若干 "段" (Segment),针对每个段分别加锁。目的也是为了降低锁竞争的概率,当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争
3. ConcurrentHashMap 在 jdk1.8 做了哪些优化?
答:取消了分段锁,直接给每个哈希桶(每个链表)都分配一个锁(以每个链表的头结点对象作为锁对象)。将原来数组+链表的实现方式改进成数组+链表/红黑树的方式,当链表较长的时候(大于等于8个元素)就转换成红黑树
4. Hashtable 和 HashMap、ConcurrentHashMap 之间的区别?
HashMap: 线程不安全;key 允许为 null
Hashtable:线程安全。使用 synchronized 锁 Hashtable 对象,效率较低;key不允许为null
ConcurrentHashMap:线程安全。使用 synchronized 锁每个链表头结点,锁冲突概率低;充分利用 CAS 机制来更新 size 属性;优化了扩容方式;key不允许为null