# 并发编程(四):并行模式与策略
# 单例模式
单例模式是一种对象创建模式,用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例。在 Java 中这样的行为能带来两大好处:
- 对于频繁使用的对象,可以省略
new
操作带来的系统开销。 new
操作减少,对内存的占用也会减少,这将减轻GC
压力,也将缩短GC
停顿时间。
单例模式的特点:
- 单例对象的构造函数访问级别为
private
。 - 单例生成的实例对象必须是
private
(安全性),也必须是static
(工厂方法必须是 static 方法)修饰的。
# 饿汉模式
饿汉模式会在类第一次初始化时创建单例对象:
public class HungrySingleton {
/**
* 私有构造函数
*/
private HungrySingleton() {}
/**
* 单例对象
*/
private static HungrySingleton instance = new HungrySingleton();
public static HungrySingleton getInstance() {
return instance;
}
}
# 懒汉模式
如果希望控制单例对象的创建时机,比如延迟到调用 getInstance()
方法调用时再创建,就需要使用懒汉模式。但是并发环境下,在获取单例的静态方法上加锁会造成性能开销;使用双重检测机制在指令重排序的情况下会造成线程不安全。因此使用懒汉模式创建单例应使用 volatile
关键字和双重检测机制。
public class LazySingleton {
/**
* 私有构造函数
*/
private LazySingleton() {
}
/**
* 单例对象
* volatile + 双重检测机制 -> 禁止重排序
*/
private volatile static LazySingleton instance = null;
/**
* instance = new LazySingleton();
* 1.分配对象内存空间
* 2.初始化对象
* 3.设置instance指向刚分配的内存
*
* JVM和CPU优化,发生了指令重排序 1-3-2,线程A执行完3,线程B执行第一个判空,直接返回
* 通过volatile关键字禁止重排序
*
* @return
*/
public static LazySingleton getInstance() {
if (null == instance) {
synchronized (LazySingleton.class) {
if (null == instance) {
// 双重检测
instance = new LazySingleton();
}
}
}
return instance;
}
}
# 使用 JVM 保证的单例模式
# 使用枚举的单例模式
public class EnumSingleton {
private EnumSingleton() {
}
private static EnumSingleton getInstance() {
return Singleton.INSTANCE.getInstance();
}
private enum Singleton {
INSTANCE;
private EnumSingleton instance;
/**
* JVM保证该方法只调用一次
*/
Singleton() {
instance = new EnumSingleton();
}
public EnumSingleton getInstance() {
return instance;
}
}
}
# 利用类初始化方式的单例
public class Singleton {
private Singleton() {}
/**
* private内部类,外部无法访问
*/
private static class SingletonHolder {
// static变量,只在加载时创建一次
private static Singleton instance = new Singleton();
}
private static Singleton getInstance() {
return SingletonHolder.instance;
}
}
# 不变模式
当存在多个线程对同一个对象进行读写操作时,为了保证对象数据的一致性和正确性,有必要对对象进行同步,这可能对系统性能造成损耗。为了尽可能去除这种同步操作,提高程序并行能力,可以使用不可变对象,即一旦被创建,其内部状态永远不会发生改变的对象。使用不变模式实际上是通过回避问题来解决问题的并发处理方案,在需求允许的情况下,不变模式可以提高系统的并发性能和并发量。java.lang.String
和基本数据类型的包装类型等都是使用不变模式实现的。不变模式的使用场景主要需要满足以下两个条件:
- 当对象创建后,其内部状态和数据不再发生任何变化。
- 对象需要被共享,被多线程频繁访问。
实现不变模式的对象需要满足以下要求:
- 没有提供任何改变自身属性的方法。
- 所有属性设为私有,并用
final
标记为不可修改。 - 确保没有子类可以通过重载修改其行为。
- 有一个可以创建完整对象的构造函数。
# 生产者-消费者模式
生产者-消费者模式是一种金典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。通常有两类线程:
- 生产者线程:负责提交用户请求。
- 消费者线程:负责处理生产者提交的任务。
生产者和消费者通过共享内存缓冲区进行通信,允许二者在执行速度上存在时间差,无论是生产者在某一局部时间内速度高于消费者还是消费者在某一局部时间内速度高于生产者,都可以通过内存缓冲区得到缓解,保证系统正常运行。
生产者-消费者模式很好地对生产者线程和消费者线程进行解耦,优化了系统整体结构。由于缓冲区的存在,生产者线程和消费者线程可以存在执行性能上的差异,从一定程度上可以缓解性能瓶颈对系统性能的影响。
# Future 模式
Future
模式的核心思想是异步调用。当需要调用一个执行很慢的方法,调用者就需要等待,有时候调用者不着急知道结果,因此可以被调用的方法立即返回,让它在后台慢慢处理这个请求。对于调用者来说,可以先处理一些其它任务,在真正需要数据的场合再去尝试获取需要的数据。
Thread 和 Runnable 创建的线程在执行完成后无法获取执行结果,但是自 Java 1.5 开始就提供了 Callable
和 Future
接口在任务执行完毕后获取结果。
/**
* 实现Callable接口的call()方法可以获取线程返回结果
*/
public interface Callable<V> {
V call() throws Exception;
}
/**
* Future对于具体的Runnable或者Callable任务的执行结果可以进行取消、查询是否取消、查询是否完成、获取结果。
* 必要时可以通过get()方法获取执行结果,该方法会阻塞直到任务返回结果。
*/
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
FutureTask
实现了 RunnableFuture
接口,而 RunnableFuture
接口继承了 Runnable
和 Future
接口,所以 FutureTask
既可以作为 Runnable
被线程执行,又可以作为 Future
得到 Callable
的返回值。
// Callable + Future
Future<String> future = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
log.info("do something in Callable");
Thread.sleep(3000);
return "Done";
}
});
String result = future.get();
// Callable + FutureTask
Future<String> future = executorService.submit(new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
log.info("do something in FutureTask");
Thread.sleep(3000);
return "Done";
}
}));;
String result = future.get();
# 高并发问题策略
# 扩容
线程占用内存的大小取决于其工作内存中变量的多少,随着并发量增加,可能就需要增加内存或服务器。
- 垂直扩容:提高系统部件能力
- 水平扩容:增加更多系统成员
# 缓存
# 缓存特征
- 命中率:命中数 / 总请求数量
- 最大元素:缓存所占空间受限制
- 清空策略:FIFO 先进先出、LFU 最少使用、LRU 最近最少使用、过期时间等
# 命中率的影响因素
- 业务场景和业务需求:要求读多的情况使用缓存
- 缓存的设计(粒度和策略):缓存粒度越小,越灵活,命中率越高;数据发送变化时,更新缓存而不是移除缓存可以提高命中率
- 缓存容量和基础设施:缓存容量受空间限制;单机缓存有瓶颈,分布式缓存易扩展
# 缓存问题
- 缓存一致性:缓存一致性要求缓存中的数据与数据库中的数据一致,缓存节点与副本中的数据一致,这就依赖缓存的过期和更新策略。
- 缓存并发问题:当缓存过期时可能有多个线程从后端系统获取数据,这可能对后端系统造成极大的冲击;当某个 key 正在更新时对这个 key 的读操作会产生一致性问题。
- 缓存穿透/击穿问题:高并发场景下多个线程对一个实际没有 value 的 key 进行查询,在未命中后会去访问后端系统,对后端系统造成不必要的压力,在这种情况下可以缓存空对象,避免请求穿透到后端系统。
- 缓存雪崩:缓存集中在某一个时间点失效,对后端系统造成很大压力,在这种情况下可以针对具体业务设置不同的缓存时间。
# 消息队列
# 特性
- 业务无关:只做消息分发
- FIFO:先投递先到达
- 容灾:节点的动态增删和消息的持久化
- 性能:吞吐量提升,系统内部通信效率提高
# 消息队列解决的问题
- 生产和消费的速度和稳定性等因素不一致时,消息队列作为缓冲
- 业务解耦,只关心业务核心处理,其它任务交由消息队列分发处理
- 最终一致性,失败重试
- 广播,新接入的业务方订阅消息自行处理
- 错峰与流控,利用消息队列转储任务,在下游系统有能力时再对任务进行处理
# 应用拆分
单个服务器的处理是有上限的,可以将一个庞大的应用按照某种规则拆分成多个应用,分开部署。
# 应用拆分原则
- 业务优先:按照业务边界对系统进行拆分
- 循序渐进:拆分后要保证系统功能完整
- 兼顾技术:系统拆分代价是高昂的,在拆分的同时对系统进行重构和分层
- 可靠测试:测试通过后才进行下一步的拆分
- 通信:RPC、消息队列
- 数据库:每个应用都有独立的数据库
- 拆分避免事务操作跨应用
# 微服务
微服务是通过将功能分解到各个离散的服务中以实现对解决方案的解耦,提供更加灵活的服务支持。微服务把一个大型的单个应用程序和服务拆分为数个甚至数十个的支持微服务,它可扩展单个组件而不是整个的应用程序堆栈,从而满足服务等级协议。微服务围绕业务领域组件来创建应用,这些应用可独立地进行开发、管理和迭代。在分散的组件中使用云架构和平台式部署、管理和服务功能,使产品交付变得更加简单。其本质是用一些功能比较明确、业务比较精练的服务去解决更大、更实际的问题。
# 应用限流
应用限流就是通过对并发访问/请求进行限制,从而达到保护系统的目的。
# 限流算法
- 计数器法:限制一定时间内的并发数(无法解决临界问题)
- 滑动窗口算法:固定时间段为一格,每格有独立的计数器,多格构成时间窗口
- 漏桶算法:请求进入漏桶,漏桶以固定速率漏出请求(执行请求),当一段时间内请求过多时就会溢出,不作处理
- 令牌桶算法:以一定的速率往桶里放令牌,每执行一个请求都要消耗一个令牌,当令牌桶中没有令牌可供消耗,则请求不作处理
# 服务降级与服务熔断
# 降级
降级指请求处理不了或出错时给一个默认的返回。
- 自动降级:超时、失败重试次数、故障、限流
- 人工降级:秒杀
# 熔断
熔断指系统出现过载,为了防止造成整个系统故障而采取的措施。
# 服务降级与服务熔断的比较
- 共性:目的一致,从可用性、可靠性着想,为了防止系统全面崩溃而使得某些服务暂时不可用;粒度大多为服务粒度;基于策略自动触发,很少人工干预
- 区别:服务降级是从整体负荷考虑的,服务熔断一般是由某个服务故障引起的
# 切库、分库分表
当单个库的数据量过大或者单个库服务器由于读写瓶颈造成服务器压力过大时,需要考虑切库、分库分表.
# 数据库切库
- 读写分离:数据库一般读多写少,读取占用的时间多,占用服务器 CPU 也较多,通常做法是把查询从主库中抽取出来,采用多个从库,使用负载均衡,减轻对原来单个数据库服务器的压力。
# 数据库分库分表
当一个表数据量很大,大到即使做了 sql 和索引优化之后,基本操作的速度还是影响使用就必须考虑分表了。分表后单表的并发能力提高了,写操作效率也会提高;其次是查询时间变短了,数据分布在不同的文件里,磁盘的 IO 性能也提高了,磁盘读写锁影响的数据量变小,插入数据库需要重新建立的索引变少。