技术文章
yarn 基本安装和使用
picgo-plugin-compress
ecshop2.7.3忘记管理员密码怎么办
技术学习路线
GitBook相关配置及优化
centos使用pm2+宝塔面板部署项目
使用Laravel/Lumen的图文详解
使用Laravel/Lumen的图文详解
MFSU Why、What、How and FAQ
固定电脑的IP地址(前后端分离接口测试必备)
前后端分离情况下,如何联调项目
宝塔 配置反向代理出现"伪静态/nginx主配置/vhost/文件已经存在全局反向代理" - 完竣世界
Windows10 MYSQL Installer 安装(mysql-installer-community-5.7.19.0.msi)
mysql-8.0.20-winx64超详细安装配置方法图文教程
mysql 安装教程(个人经验,仅供参考)
ubuntu server 安装 question2answer 及 汉化包
PHP环境安装libevent扩展
Vercel ,开箱即用的网站管理小工具
Ubuntu 可视化界面
使用宝塔 Linux 面板快速迁移网站
win10安装子系统ubuntu附带图形化界面
Win10子系统-Ubuntu安装及配置VNC访问XFCE4桌面
阿里云-轻量应用服务器-Ubuntu-图形界面-xfce-VNC远程连接
Python实现Word文档转换Markdown
虚拟机MacOS10.14全屏问题
MacOS X 安装 VMware tools
VM15虚拟机下安装苹果MAC OS Mojave10.14.6
“双11”必备的3个设计模式
本文档由 Assistants 发布
-
+
home page
“双11”必备的3个设计模式
<p data-nodeid="525" class="">大家好,我是山海。</p> <p data-nodeid="526">“双11”到了,很多技术同学还在加班加点备战购物高峰。既然说到这里,本期内容我就来跟大家聊一聊与“双11”相关性最强的并发型模式和结构模式,同时,它们也是你在工作和面试中最常涉及到的设计模式。</p> <h3 data-nodeid="527">生产者与消费者模式</h3> <p data-nodeid="528">如果你是 5 年左右的开发,也许你已经发现,在面试时并发型模式基本是必考题目。这是为什么呢?因为候选人对这种模式的理解和应用能很好得体现出他的并发技术功底。而且在实际工作中,只要你熟知并发模式,就不会闹出低级的并发问题。</p> <p data-nodeid="529">所以,无论是为了面试,还是为了实际工作,掌握并发型模式都是非常有必要的。而生产者与消费者模式是对并发场景最基本的抽象,可以理解为是一种最常见的并发场景。</p> <p data-nodeid="530">我们可以认为,它主要应用场景是,在容器固定的情况下,有多个生产的线程与多个消费的线程,同时往容器里放物品与获取容器内的物品。这时候,如果并发操作写得不够严谨,就会导致容器里放了超过容器最大容量的物品,这明显是不正确的。或者可能导致明明容器中已经没有物品了,还能进行消费,这明显也是有问题的。</p> <p data-nodeid="531">举个例子,很多同学都参加过秒杀活动,比如我们定 100 个产品参与秒杀,但如果代码写的不足够严谨,就可能有超过 100 个用户秒杀到产品,那就会给公司带来损失,或者导致有一些用户得不到秒杀的产品,这样的程序明显是有问题的。</p> <p data-nodeid="532">那我们先模拟出这个有问题的程序,然后再给针对性地给出 3 种解决方案,来解决这个问题。</p> <pre class="lang-plain" data-nodeid="533"><code data-language="plain">public class Storage { // 最大存储量,由于不同商品,可能数量不同,所以,可以传入进来 private int size; // 存储商品的的容器 private LinkedList<Object> list = new LinkedList<Object>(); //初始化容器时,需要初始化大小 public Storage(int size) { this.size = size; } // 生产n个产品 public void produce(int n) { for (int i = 1; i <= n; ++i) { if (list.size() > size) { System.out.println("容器满了,不可以再放入了,此时物品数量为:" + list.size()); return; } list.add(new Object()); } System.out.println("生产" + n + "个产品,当前容器内的数量为:" + list.size()); } // 消费n个产品 public void consume(int n) { for (int i = 1; i <= n; ++i) { if (list.size() == 0) { System.out.println("容器空了,不能再消费了,此时容器内的数量为:" + list.size()); return; } list.remove(); } System.out.println("消费" + n + "个产品,当前容器内的数量为:" + list.size()); } } </code></pre> <p data-nodeid="534">这里,我们可以先把消费者与生产者也定义出来,如下:</p> <pre class="lang-plain" data-nodeid="535"><code data-language="plain">/** * 消费者 */ class Consumer extends Thread { // 每次消费的数量 private int n; // 所在放置的容器 private Storage storage; // 构造函数,设置仓库 public Consumer(int n, Storage storage) { this.n = n; this.storage = storage; } // 新线程,来消费 public void run() { storage.consume(n); } } /** * 生产者 */ class Producer extends Thread { // 每次生产的产品数量 private int n; // 所在放置的仓库 private Storage storage; // 构造函数,设置仓库 public Producer(int n, Storage storage) { this.n = n; this.storage = storage; } // 线程run函数 public void run() { storage.produce(n); } } </code></pre> <p data-nodeid="536">下面,我们写一个多线程的测试程序,你也可以复制粘贴到自己的编译器里实际跑一下。</p> <pre class="lang-plain" data-nodeid="537"><code data-language="plain">class Test { public static void main(String[] args) { // 创建容器对象 Storage storage = new Storage(100); // 生产者对象 Producer p1 = new Producer(10, storage); Producer p2 = new Producer(20, storage); Producer p3 = new Producer(30, storage); Producer p4 = new Producer(20, storage); Producer p5 = new Producer(20, storage); Producer p6 = new Producer(20, storage); Producer p7 = new Producer(10, storage); // 消费者对象 Consumer c1 = new Consumer(10, storage); Consumer c2 = new Consumer(10, storage); Consumer c3 = new Consumer(30, storage); // 线程开始执行 c1.start(); c2.start(); c3.start(); p1.start(); p2.start(); p3.start(); p4.start(); p5.start(); p6.start(); p7.start(); } } </code></pre> <p data-nodeid="538">多执行几次代码你就会发现,由于多线程的存在,有时候容器的数量会多于 n 个,如果我们在测试程序里面多设置了消费者的话,就可能导致报错。是因为可能容器里面已经没有产品了,但还有线程去取产品。</p> <p data-nodeid="539">那这个程序如何变成线程安全的呢?方案有很多,我建议你现在先停下来,不去看后面的代码,自己来改写一下,看看可以改出多少种方案。</p> <p data-nodeid="540">下面,来跟我一起把 3 种方案逐一实现出来。</p> <h4 data-nodeid="541">sychorinzed 实现方案</h4> <pre class="lang-plain" data-nodeid="542"><code data-language="plain">public class Storage { // 最大存储量,由于不同商品,可能数量不同,所以,可以传入进来 private int size; // 存储商品的的容器 private LinkedList<Object> list = new LinkedList<Object>(); //初始化容器时,需要初始化大小 public Storage(int size) { this.size = size; } // 生产n个产品 public void produce(int n) { //加锁,这里一定要注意,所有的生产者、消费者一定要锁同一个对象 synchronized (list) { //注意,这里一定要用 while,因为 wait可能存在被错误唤醒的时候,一定醒了要重新判断条件 while (list.size() + n > size) { System.out.println("容器满了,不可以再放入了,此时物品数量为:" + list.size()); try { //满了就等一下 list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } for (int i = 1; i <= n; ++i) { list.add(new Object()); } } //生产完成,叫醒其他生产者和消费者 list.notifyAll(); System.out.println("生产" + n + "个产品,当前容器内的数量为:" + list.size()); } // 消费n个产品 public void consume(int n) { //加锁,这里一定要注意,所有的生产者、消费者一定要锁同一个对象 synchronized (list) { //注意,这里一定要用 while,因为 wait可能存在被错误唤醒的时候,一定醒了要重新判断条件 while (list.size() - n < 0) { System.out.println("容器空了,不能再消费了,此时容器内的数量为:" + list.size()); try { list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } for (int i = 1; i <= n; ++i) { list.remove(); } } //生产完成,叫醒其他生产者和消费者 list.notifyAll(); System.out.println("消费" + n + "个产品,当前容器内的数量为:" + list.size()); } } </code></pre> <p data-nodeid="543" class="">用 synchronized 的方式实现生产者与消费者,是最基础的一种实现方式,也是最能看出线程安全的一种方式。而在整个实现过程中,你一定要注意以下几点。</p> <ol data-nodeid="544"> <li data-nodeid="545"> <p data-nodeid="546">加锁的对象一定是同一个,这也是让容器自己实现生产方法与消费方法的好处。</p> </li> <li data-nodeid="547"> <p data-nodeid="548">一定要用 while 来判断是否 wait 的条件,千万不能用 if,否则一定出错,这里是非常容易忽略的点,非常难发现。</p> </li> <li data-nodeid="549"> <p data-nodeid="550">最后一定要用 notifyAll,而不能用 notify(),因为 notify 不能确定唤醒的对象。</p> </li> </ol> <h4 data-nodeid="551">lock 实现方案</h4> <p data-nodeid="552">第一种方案理解好以后,我们再一起看看 lock 的实现方式。比较一下,你会发现 lock 的方式感觉更自然,更加好理解。</p> <pre class="lang-plain" data-nodeid="553"><code data-language="plain">public class Storage { // 最大存储量,由于不同商品,可能数量不同,所以,可以传入进来 private int size; // 存储商品的的容器 private LinkedList<Object> list = new LinkedList<Object>(); private final ReentrantLock lock = new ReentrantLock(); private final Condition produce = lock.newCondition(); private final Condition consume = lock.newCondition(); //初始化容器时,需要初始化大小 public Storage(int size) { this.size = size; } // 生产n个产品 public void produce(int n) { //加锁 lock.lock(); try { //注意,这里用 while和 if 的区别就不大了 while (list.size() + n > size) { System.out.println("容器满了,不可以再放入了,此时物品数量为:" + list.size()); produce.await(); } for (int i = 1; i <= n; ++i) { list.add(new Object()); } //生成完成,叫消费者 consume.signal(); }catch (Exception e){ e.printStackTrace(); }finally { lock.unlock(); } System.out.println("生产" + n + "个产品,当前容器内的数量为:" + list.size()); } // 消费n个产品 public void consume(int n) { //加锁 lock.lock(); //注意,这里用 while和 if 的区别就不大了 try { while (list.size() == 0) { System.out.println("容器空了,不能再消费了,此时容器内的数量为:" + list.size()); consume.await(); } for (int i = 1; i <= n; ++i) { list.remove(); } //生产完成,叫醒其他生产者和消费者 produce.signal(); }catch(Exception e) { e.printStackTrace(); }finally { lock.unlock(); } System.out.println("消费" + n + "个产品,当前容器内的数量为:" + list.size()); } } </code></pre> <p data-nodeid="554" class="">lock 实现方案更加清晰易懂,这和 lock 的语法有很大的关系。但这里一定要注意在 finally 里面解锁,以避免异常导致锁不能释放的场景。</p> <p data-nodeid="555" class="">这种方式的写法和使用分布式——比如 Redis 分布式锁——的写法非常类似,这种写法也是非常有意思的,我们在现实生活中更多的是使用分布式锁来实现这个场景。</p> <h4 data-nodeid="556">阻塞队列实现方案</h4> <p data-nodeid="557">下面再来看最后一种实现生产者与消费者模式的方案——阻塞队列。</p> <pre class="lang-plain" data-nodeid="558"><code data-language="plain">public class Storage { // 存储商品的的容器 private LinkedBlockingQueue<Object> list; //初始化容器时,需要初始化大小 public Storage(int size) { list = new LinkedBlockingQueue<>(size); } // 生产n个产品 public void produce(int n) { for (int i = 1; i <= n; ++i) { try { list.put(new Object()); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("生产" + n + "个产品,当前容器内的数量为:" + list.size()); } // 消费n个产品 public void consume(int n) { for (int i = 1; i <= n; ++i) { try { list.take(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("消费" + n + "个产品,当前容器内的数量为:" + list.size()); } } </code></pre> <p data-nodeid="559">有同学会觉得,这种实现方式很简单呀。其实你看一下 put 和 take 的代码,写法和我们的上面的 lock 的代码非常类似,可以理解为这个阻塞队列本身给我们实现了生产者与消费者模式。</p> <p data-nodeid="560">这个设计模式其实有很多的应用场景,我给大家举个例子。比如,我们经常需要写一个线程数量限制的过滤器,以保证服务在并发量突然暴增的情况下,还能有一定量的服务在正常运行,把多余的、无法承受的访问直接返回,这其实就是生产者消费者模式的一种变形应用。</p> <p data-nodeid="561">还有就是,有的时候我们要给不同的调用方一定量的线程,也可以通过上面的代码调整一下来实现。你可以自己手动尝试写一下,有问题可以分享到评论区。</p> <p data-nodeid="562">最后强调下,生产者消费者模式非常重要,特别是对 3~5 年的同学来说。它在面试过程中经常被考到,但我发现很多同学反而忽略它,这一点一定要注意!</p> <h3 data-nodeid="563">代理模式</h3> <p data-nodeid="564">下面我们一起来看结构模式中最重要的一个——代理模式。</p> <p data-nodeid="565">代理模式的重要性,相信学习过框架的同学应该都有所了解。正因为有动态代理,才有了切面编程,才成就了我们众所周知的框架 Spring。</p> <p data-nodeid="566">其实不仅仅是 Spring,可以说所有的框架都离不开动态代理,因为所有的框架基本都有对代码统一处理的诉求,而且这种诉求一定是无侵入的。想要达成这个诉求,真的是非动态代理莫属。</p> <p data-nodeid="567">可能有一些刚刚学习技术的同学好奇了,这么强大的代理模式到底是用来完成什么功能的呀?</p> <p data-nodeid="568">很简单,它就是在我们实现的基本功能的基础上,对这些实现做一些统一的处理,比如日志、事物、计数等。</p> <p data-nodeid="569">这些操作都是统一操作,而且是我们基本的实现无感知的,为了完成这些工作,我们就需要生成相应的代理对象,在完成基本工作同时,又会完成这些附加工作。</p> <p data-nodeid="570">那么既然有动态代理,对应的就应该有静态代理。我们先了解一下,静态代理是如何实现的,因为静态代理虽然比较少用,但能很好得帮助我们理解代理模式。</p> <pre class="lang-plain" data-nodeid="571"><code data-language="plain">//所有的类为了标准化,一定要实现统一的接口 public interface People { public void work(); } public class Teacher implements People{ @Override public void work() { System.out.println("教学中。。。"); } } public class TeacherProxy implements People{ //真正要代理的类 Teacher teacher; public TeacherProxy(Teacher teacher){ this.teacher = teacher; } //在代理的方法里,实现了一些辅助的功能,最终的核心功能,还是由真正的实现类执行 @Override public void work() { System.out.println("==========准备教案!============="); //实质上在代理类中是调用了被代理实现接口的方法 teacher.action(); System.out.println("==========批改作业!==========="); } } </code></pre> <p data-nodeid="572">怎么样,这个静态代理你看懂了么?其实它就像是我们的助手一样,完成了一些辅助功能,让我们把主要的工作和次要的工作区分开,核心逻辑我们自己完成,辅助工作在助手里来完成。</p> <p data-nodeid="573">那么问题来了!如果我们有一批这样的类,都需要同样的辅助工作,那怎么办呢?这个时候,我们的动态代理就应该登场了。针对于这个问题,我们可以用如下方法来实现:</p> <pre class="lang-plain" data-nodeid="574"><code data-language="plain">public class TeacherInvocationHandler<People> implements InvocationHandler { //持有的被代理对象 People p; public TeacherInvocationHandler(People p) { this.p = p; } /** * 此方法是代理方法,附加工作在此法 * proxy:代表动态代理对象,在调用时传入 * method:代表代理的方法 * args:代表调用目标方法时传入的参数 */ @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("==========准备工作!============="); //执行代理类的方法 Object result = method.invoke(target, args); System.out.println("==========收尾工作!============="); return result; } } </code></pre> <p data-nodeid="575" class="">注意,这里的调用会比较特别,要使用 Proxy 类来进行调用,这个也是 jdk 给我们封装好的类,我们可以理解为固定的 API,直接使用就好,如:</p> <pre class="lang-plain" data-nodeid="576"><code data-language="plain"> //创建一个实例对象,这个对象是被代理的对象 Person teacherW = new Teacher("wang"); //创建代理对象 InvocationHandler teacherHandler = new TeacherInvocationHandler<Person>(teacherW); //代理对象的每个执行方法都会替换执行Invocation中的invoke方法 Person teacherProxy = (Person) Proxy.newProxyInstance(Person.class.getClassLoader(), new Class<?>[]{Person.class}, teacherHandler); //执行代理方法 teacherProxy.work(); </code></pre> <p data-nodeid="577">有些读者反馈这里的代理方法不太好理解,但没关系,这些都是死的写法,就是 jdk 标准的实现,大家能背下或者在用的时候直接照着这个例子修改就可以,你只要明白代理的大体流程就基本够用了。</p> <p data-nodeid="578">其实我们很多实际工作场景中用的并不是 jdk 自带的动态代理,而是 CGLIB 框架给我们封装的动态代理。这种动态代理的语法更加好理解,由于篇幅原因,就不在这里写具体的例子了,有兴趣的同学可以自己尝试在网上找一些资料。你不妨把上面的例子改写一下,写出来后你会发现,真的非常简单。</p> <p data-nodeid="579" class="">那么,动态代理背后的原理是什么呢?CGLIB 是对 jdk 的进一步封装么?</p> <p data-nodeid="920" class="te-preview-highlight">实际上并不是这样的。无论是 jdk 的实现还是 CGLIB 的实现,都是使用了 ASM 这个字节码框架,它可以帮助我们实现任何一个类,而且是 javac 编译好的那种,可以在内存中直接执行。</p> <p data-nodeid="581">利用 ASM,我们的 jdk 在内存中给我们生成了类似于静态代理的代理类,这里的代理类一定和被代理类实现同一个接口。但 CGLIB 不受这种限制,CGLIB 是生成了一些继承于原来的被代理类,所以,可以实现不同的接口。</p> <p data-nodeid="582">大家如果有兴趣可以学习一下 ASM 框架,但其实在面试中,能回答出 ASM,基本就可以达到面试官的要求了,我们在实际工作中,很少直接使用到 ASM 框架。</p> <h3 data-nodeid="583">适配器模式</h3> <p data-nodeid="584">另一种非常重要的结构模式是适配器模式。</p> <p data-nodeid="585">什么时候用到适配器模式呢?我举个我们工作中经常遇到的例子。有一天前端同学找到后端同学说:“为什么不同的类型需要调用不同的接口呀?我只要告诉你相关的类型,和相关的处理参数,后端返回相关的结果就好了呀!”</p> <p data-nodeid="586">你是不是也对这个场景非常熟悉?但此时如果你是这个后端同学,肯定会非常烦恼:后端就是多类实现类,而且这样也是符合面向对象的实现呀,为什么要合成一个呢?</p> <p data-nodeid="587">说说看你遇到这种情况,通常是怎么解决呢?根据我的经验,这个时候就是适配器模式登场的时候。</p> <p data-nodeid="588">我们来看一个例子的代码:</p> <pre class="lang-plain" data-nodeid="589"><code data-language="plain">public interface Player { public void play(String fileName); } public class VideoPlayer implements Player{ @Override public void play(String fileName) { System.out.println("Play video: "+ fileName); } } public class AudioPlayer implements Player{ @Override public void play(String fileName) { System.out.println("Play audio: "+ fileName); } } </code></pre> <p data-nodeid="590">你看上面的例子,视频播放器和音频播放器是两个类,可以播放不同类型的文件,是不是觉得这样很合理?但是有的时候,使用者就想把类型和文件名作为两个参数,访问同一个接口使用,仔细想想,这个想法其实也是合理的。那么此时,我们就需要一个适配器了。你再看下面的实现代码:</p> <pre class="lang-plain" data-nodeid="591"><code data-language="plain">public interface MediaPlayer { public void play(String type, String fileName); } public class MediaAdapter implements MediaPlayer { Player player; @Override public void play(String type, String fileName) { if(audioType.equalsIgnoreCase("video") ){ player = new VideoPlayer(); } else if (audioType.equalsIgnoreCase("audio")){ player = new AudioPlayer(); } player.play(fileName); } } </code></pre> <p data-nodeid="592">我们的适配器这样就完成了,后端没有打破面向对象的编程模式,前端也能很方便地去调用。而且使用适配器还有一个好处,我们有哪些地方对外开放可以在适配器里面控制,加强了我们程序的安全性。</p> <h3 data-nodeid="593">总结</h3> <p data-nodeid="594">本期内容中,我们重点讲解了生产者与消费者模式,并且讲解了最重要的两种结构模式,怎么样,你有收获吗?</p> <p data-nodeid="595">由于篇幅原因,我只说了设计模式中,我认为对你的工作和面试更加重要的部分。对于其他的结构模式,比如装饰器模式、外观模式、桥接模式、组合模式、享元模式,大家也要抽时间学习一下。特别是桥接模式,要注意在学习的同时与其他的模式进行对比,能有助于你更好地理解与运用这些模式。</p> <p data-nodeid="596" class="">好的,本期内容就到这里,大家有什么问题欢迎在评论区留言。我是山海,再见。</p> --- ### 精选评论
子升
Nov. 24, 2022, 4:19 a.m.
Share documents
Collection documents
Last
Next
Scan wechat
Copy link
Scan your mobile phone to share
Copy link
Download markdown file
share
link
type
password
Update password