Java 并发基础之并发编程通识
引言
并发编程是一个经典的话题,由于摩尔定律已经改变,芯片性能虽然仍在不断提高,但相比加快 CPU 的速度,计算机正在向多核化方向发展。虚拟化的赋能,让多核服务器的弹性创建和扩容都更加便捷。为了尽可能的提高程序的性能,硬件,操作系统,程序编译器进行一系列的设计和优化,但是同时,带来了影响并发安全的3类问题:
CPU 增加了缓存,以均衡与内存的速度差异,但是带来了可见性问题
操作系统增加了线程,行程,分时复用 cpu 以增加 cpu 利用率,但是带来的线程切换的原子性问题
编译程序优化指令次序,但是带来了有序性问题
Java 作为互联网后端最主流的高级语言,以及大数据工程的事实标准语言,从诞生初始并发编程就是其重要特性之一。Java 提供了许多基本的并发功能来辅助多线程应用程序的开发。从 1.5 之前基于管程模型的同步锁,到 1.5 内存模型重构后广泛使用的 CAS+AQS 的乐观模型,随着版本的演进,并发编程的操作难度越来越低,但是另一方面,相对底层的并发功能与上层的应用程序的并发语义之间并不存在一种简单而直观的映射关系。
因此,即使面对众多并发工具,开发人员可能也陷入着无法选取合理武器的困局,为了能正确且高效的使用这些功能,对 Java 提供的并发工具有一个系统的大局观并了解其原理是 Java 开发人员必须关注的重点。本文将通过几个最具有代表性的问题的剖析,展现 Java 并发设计的核心关键点,为最佳实践打好理论基础。
synchronized 和 Lock 可以互相替代吗?
synchronized 是 Java 1.0 即加入的并发解决方案,其原理为只支持一个条件变量的简化后的 MESA 模型。而 Lock 是 Java 1.5 加入的基于完整 MESA 模型的 Api 原语。解答是否可以互相替代的问题,可以先从区别比较入手:
比较项目 | synchronized | Lock |
---|---|---|
形态 | Jvm 层面的关键字 | Java语言层面的Api |
管程模型 | 只支持一个条件变量的简化后的MESA模型 | 支持多个条件变量的完整MESA模型 |
锁的获取 | 进入同步代码块即开始竞争锁,未获得锁的线程会一直等待 | 可以通过API实现多种多样的的竞争 |
锁的释放 | 1.持有锁的线程发生异常,Jvm 强制线程释放锁 2.拥有锁的线程执行完同步代码块,自动释放 | 基于 Api 的手动释放 |
锁类型 | 可重入,不可中断,不可公平 | 可重入,可中断,可公平 |
锁状态 | 无法判断 | 通过 Api 判断 |
取舍 | 1.6 优化后性能是Lock 的两倍 | 基于管程语义的 Api 功能更强大 |
通过表格中的对比非常明显的得出,Lock 可以在大部分情况下替换 synchronize,但是反过来不然。对于两者的使用,有以下最佳实践方案:
- 优先使用 synchronized,当不满足并发需求时使用 Lock,如多个条件变量,希望竞争公平等
- 使用 Lock 时注意两个范式:try-finally 和 乐观自旋
下面是两种工具实现的阻塞队列,其间区别非常明显:
synchronized
//很标准的模式,没有扩展点
public class BlockQueue {
private final int maxSize;
private LinkedList values;
BlockQueue(int size) {
maxSize = size;
values = new LinkedList<>();
}
public void put(int value) throws InterruptedException{
// 可以锁 values ,也可以锁 BlockQueue.class
synchronized (values) {
while (values.size() == maxSize) {
try {
values.wait();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
values.add(value);
values.notifyAll();
}
}
public int take() throws InterruptedException{
synchronized (values) {
while (values.size() == 0) {
try {
values.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
values.notifyAll();
return values.removeFirst();
}
}
}
Lock
public class BlockQueue {
private final int maxSize;
private ReentrantLock lock;
private Condition notFull;
private Condition notEmpty;
private LinkedList values;
BlockQueue(int size) {
//公平锁,讲究先来后到
lock = new ReentrantLock(true);
// 两个条件变量
notFull = lock.newCondition();
notEmpty = lock.newCondition();
maxSize = size;
values = new LinkedList<>();
}
public void put(int value) {
/ /尝试 1 分钟
lock.tryLock(1, TimeUnit.MINUTES);
// try-finally范式
try {
// while范式,可以判断锁的状态
while (values.size() == maxSize && lock.isLocked()) {
//阻塞线程至相应条件变量的等待队列
notFull.await();
}
values.add(value);
//唤醒相应条件变量的等待队列中的线程
notEmpty.signalAll();
} catch (Exception e) {
} finally {
lock.unlock();
}
}
public int take() {
int value;
//获取可以被中断的锁,当被中断时,需要处理InterruptedException
lock.lockInterruptibly();
try {
while (values.size() == 0 && lock.isLocked()) {
notEmpty.await();
}
notFull.signalAll();
} catch (InterruptedException e) {
if (Thread.currentThread().isInterrupted() {
System.out.println(Thread.currentThread().getName() + " interrupted.");
}
} finally {
value = values.poll();
lock.unlock();
}
return value;
}
}
当然,在 Java 中是不需要自己手写阻塞队列的,Java 1.8 并发包中提供了7种实现,满足各类场景的需求
如何按需定制一个线程池
在并发处理的场景下,程序可能要频繁的创建线程工作,完毕后销毁。虽然Java中创建线程就像 new 一个对象一样简单,销毁也是 Jvm 的 GC 自动搞定的。但实际上创建线程是非常复杂的。创建一个普通对象,仅仅是在 Jvm 的堆内存中划分一块内存而已。而创建一个线程,却需要调用操作系统的 Api 分配一系列资源,这个成本和对象无法相提并论的。
程序中应该避免频繁创建和销毁如此重量级的线程对象,标准的解决方案就是池技术,在 Java 中, ThreadPoolExecutor
就是线程池工具。
不同于标准的池模型, ThreadPoolExecutor
没有 acquire方法
获得资源,没有 release方法
释放资源,其通过 7 个构造参数构建了生产者-消费者的模式。
线程池的内部核心原理是内部通过阻塞队列来缓存任务,调用 execute方法
的线程为生产者,内部的一组工作线程为消费者,获得 Runnable
任务并执行。
正确使用线程池就是正确配置其构造参数,有以下最佳实践或注意事项:
共有 0 条评论