常见锁策略

news2024/10/7 14:33:47

一、乐观锁和悲观锁:

(一)乐观锁 和 悲观锁概念

悲观锁:总是假设最坏的情况,认为每次读写数据都会冲突,所以每次在读写数据的时候都会上锁,保证同一时间段只有一个线程在读写数据。

乐观锁:每次读写数据时都认为不会发生冲突,线程不会阻塞,只有进行数据更新时才会检查是否发生冲突,若没有冲突,直接更新,只有冲突了(多个线程都在更新数据)才解决冲突问题。

举栗时间:

同学A 和同学B想请教老师一个问题.

同学A认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学A会先给老师发消息: "老师,你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学B认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学B直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

(二)乐观锁 和 悲观锁的实现

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.

乐观锁的实现可以引入版本号机制. 借助版本号识别出当前的数据访问是否冲突.

(核心就是,线程能否成功刷新主内存的值。当工作内存的版本号 == 主内存的版本号才能更新,更新成功之后,同步刷新自己的版本号和主内存的版本号,表示此时更新成功。)

1) 线程1 此时准备将其读出( version=1, balance=100 ),线程 2 也读入此信息( version=1, balance=100 )

2) 线程 1 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 2从其帐户余额中扣除 20 ( 100-20 ); 

3) 线程 1完成修改工作,连同帐户扣除后余额( balance=50 ),写回到内存中,并将工作内存版本号和主内存版本号加1( version=2 ),; 

4) 线程 2 完成了操作,试图向主内存中提交数据( balance=80 ),但此时比对版本发现自己工作内存的版本号 != 主内存的版本号。就认为这次操作失败. 

当线程2的version和主线程的version不相等时,有两种解决办法

1.直接报错,线程2退出,不写回

2.线程2从主内存中读取最新的值50以及版本号2,再次在50的基础上执行-20操作 = 30,然后尝试重新写会主内存。 (CAS策略:不断重试写会,直到成功为止

一般锁的实现都是乐观锁和悲观锁并用的策略。

二、读写锁(适用于线程基本上都在读数据,很少有写数据的情况)

多线程访问数据时,并发读取数据不会有线程安全问题,数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

将锁分为读锁和写锁

  1. 多个线程并发访问读锁(读数据),则多个线程都能访问到读锁,读锁和读锁是并发的,不互斥。
  2. 两个线程都需要访问写锁(写数据),则这两个线程互斥,只有一个线程能成功的获取到写锁,其他写线程阻塞。
  3. 当一个线程读,另一个线程写(也互斥,只有当写线程结束,读线程才能继续执行。)

        例子:当读者在追连载小说时,只能等作者写完,读者才能阅读

Synchronized不是读写锁,JDK内置了另一个ReentrantReadWriteLock实现读写锁。

三、重量级锁和轻量级锁:

重量级锁: 重量级锁的加锁机制重度依赖操作系统提供的 mutex (互斥锁),线程获取重量级锁失败进入阻塞状态,操作系统进行状态的切换(操作系统频繁地从用户态切换到内核态,或者从内核态切换到用户态),开销非常大。)

轻量级锁:轻量级锁的加锁机制尽可能不使用操作系统的mutex, 而是尽量在用户态执行操作, 实在搞不定了, 再使用 mutex.(很少)进行状态切换,开销较小。

四、自旋锁

while(获取lock == false){
    //不断地循环
}

自旋锁表示当线程获取锁失败后,并不会让出CPU,线程也不阻塞,不会从用户态切换到内核态,而是在CPU上空跑,当锁被释放,此时这个线程就会很快获取到锁。 

理解自旋锁 vs 挂起等待锁

想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~ 挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).

自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位.

优点: 自旋锁没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用. 

缺点:如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源

轻量级锁的常用实现就是采用自旋锁:

五、公平锁和非公平锁:

公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,第一个进入阻塞队列的线程首先获取到锁(等待时间最长的线程获取到锁)。

非公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,所有在队列中的线程都有机会获取到锁,获取到锁的线程不一定就是等待时间最长的线程。(synchronized就是非公平锁)

六、可重入锁 和不可重入锁

可重入锁:可重入锁的字面意思是“可以重新进入的锁”,获取到对象锁的线程可以再次加锁,这种操作就称为可重入。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。 线程安全问题_explorer363的博客-CSDN博客

不可重入锁: Linux 系统提供的 mutex 是不可重入锁.

一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁

七、CAS 

(一)什么是CAS?

CAS(Compare and swap)比较并交换,是乐观锁的一种实现方法。不会真正阻塞线程,而是不断尝试更新。

CAS的操作流程:

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

        1. 比较 A 与 V 是否相等。(比较)

        2. 如果比较相等,将 B 写入 V。(交换)

            若比较不相等,说明此时线程的值A已经过时啦(即主内存中的值已经发生了变化),将当前主内存的最新值V保存到当前的工作内存中,此时无法将B写回主内存,继续循环,直到 A== V,将 B 写入 V.

        3. 返回操作是否成功。

(二)由CAS实现的应用

(1)CAS实现原子类

int i = 0;

i++ ;i--等操作都是非原子性操作,多线程并发会产生线程安全问题。

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

public class AtomicTest {
    static class Counter {
        // 基于整型的原子类
        AtomicInteger count = new AtomicInteger();
        void increase() {
            // i++
            count.incrementAndGet();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count.get());
    }
}

(2)CAS实现自旋锁

自旋锁指的是获取锁失败的线程,不会让出CPU,不会进入阻塞队列,而是在CPU上空转,不断查询当前锁的状态。

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;
   }
}

(三)由CAS引发的ABA问题

 此时有两个线程t1,t2,同时修改共享变量num,num== A;

正常情况:

只有一个线程会将num值该为正确的值,而另一个线程则无法修改,因为这个线程的值已经过期了(num != A)

1. num == A        

2.  t1 --> num == B  

3.  CAS(V,A,B) 其中 V== B,A==A,B ==Z   V != A,t2在CAS的过程中,主内存的值与工作内存的值不相等,因此,无法修改,需要将最新值读取到工作内存后再次尝试CAS。

特殊情况:

当一个线程连续对num值进行了修改,且最后一次修改将num值又重新修改为了它原来的值,对于另一个线程来说,是可以成功修改num的值的,因为主内存的值 == 工作内存的值

 t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

解决方案:引入版本号机制 

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 

CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候, 如果当前版本号(主内存)和读到的版本号(工作内存)相同, 则修改数据, 并把版本号 + 1.

如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了). 

八、synchronized关键字背后锁的升级流程

基本特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

        1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

        2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

        3. 实现轻量级锁的时候大概率用到的自旋锁策略

        4. 是一种不公平锁

        5. 是一种可重入锁

        6. 不是读写锁

JVM对synchronized锁的升级流程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级(自动升级,程序员不用做任何处理)。

   synchronized void increase() {
           val++;
   }

1)无锁:没有任何线程尝试获取锁 (此时没有任何线程调用increase(),因此对象就是无锁状态)

2)  偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态.

        偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 当这个线程再次进行其他同步操作时(重入或再次执行),就验证下是否为被标记线程,若是直接放行(避免了加锁解锁的开销)

        当有第二个线程也在尝试获取锁后(开始有竞争后),JVM就会取消偏向锁状态,将锁升级为轻量级锁。

3) 轻量级锁: 随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

         此处的轻量级锁就是通过 CAS 来实现.通过 CAS 检查并更新一块内存 (比如 null => 该线程引用) 如果更新成功, 则认为加锁成功

        如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

第二个尝试加锁的线程通过自旋的方式来获取轻量级锁,如果还有其他线程在想尝试获取锁,都在自旋等待第二个线程执行结束。

4) 重量级锁 如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁(悲观锁的实现)

竞争非常激烈,多个线程都同时在竞争轻量级锁(一般来说就是当前线程数为CPU核数的一半),or 自旋次数超过默认值以上 , 就会将轻量级锁膨胀为重量级锁。

只要程序中调用了wait()方法,直接会膨胀为重量级锁,无论当前竞争是否激烈

        此处的重量级锁就是指用到内核提供的 mutex .

        执行加锁操作, 先进入内核态. 在内核态判定当前锁是否已经被占用

        如果该锁没有占用, 则加锁成功, 并切换回用户态.

        如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 等待被操作系统唤醒.

经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

 优化操作

1、锁消除

        编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

         此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销.因此,JVM会自动进行锁消除。

2、锁粗化

        一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

        实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.

九、java.util.concurrent.lock

Lock接口是标准库的一个接口, 在 JVM 外实现的(基于 Java 实现). 

使用Lock接口需要显示的进行加锁和解锁操作。

lock(): 加锁, 获取锁失败的线程进入阻塞状态,直到其他线程释放锁,再次竞争,如果获取不到锁就死等. 
trylock(超时时间): 加锁, 获取锁失败的线程进入阻塞状态, 等待一定的时间,时间过了若还未获取到锁恢复执行,就放弃加锁,执行其他代码 
unlock(): 解锁

synchronized和lock的区别:

        1.synchronized是Java的关键字,由JVM实现,需要依赖操作系统提供的线程互斥锁(mutex);Lock是标准库的接口,其中一个最常用子类(ReentrantLock,可重入锁),由Java本身实现,不需要依赖操作系统。

        2.synchronized 是隐式的加锁和解锁,而lock需要显示进行加锁和解锁

        3.synchronized 在获取锁失败的线程时,死等;lock可以使用tryLock等待一段时间之后自动放弃加锁,线程恢复执行。

        4.synchronized是非公平锁,RenntrantLock默认是非公平锁,可以在构造方法中传入true开启公平锁。 

 public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

        5.synchronized不支持读写锁,Lock的子类ReentrantReadWriteLock支持读写锁。

一般场景下,使用synchronized足够用了,需要用到超时等待锁,公平锁,读写锁再考虑使用lock接口。

十、死锁

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线 程被无限期地阻塞,因此程序不可能正常终止。

举个栗子理解死锁

滑稽老哥和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.

滑稽老哥抄起了酱油瓶, 女神抄起了醋瓶.

滑稽: 你先把醋瓶给我, 我用完了就把酱油瓶给你.

女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.

如果这俩人彼此之间互不相让, 就构成了死锁.

酱油和醋相当于是两把锁, 这两个人就是两个线程.

如何避免死锁

死锁产生的四个必要条件:

互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用

不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。

循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路。

当上述四个条件都成立的时候,便形成死锁。

当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。 其中最容易破坏的就是 "循环等待".

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/991614.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

从 算力云 零开始部署ChatGLM2-6B 教程

硬件最低需求&#xff0c;显存13G以上 基本环境&#xff1a; 1.autodl-tmp 目录下 git clone https://github.com/THUDM/ChatGLM2-6B.git然后使用 pip 安装依赖&#xff1a; pip install -r requirements.txtpip 使用pip 阿里的 再执行git clone之前&#xff0c;要先在命令行…

[GKCTF 2021]Crash

目录 恢复符号表&#xff0c;找到main函数 四部分加密 第一部分 第二部分 第三部分 第四段 恢复符号表&#xff0c;找到main函数 观察特征&#xff0c;应该是Go语言&#xff0c;函数没有名字&#xff0c;用脚本恢复符号表&#xff08;脚本自己去找一个&#xff09; 四部…

MySQL - binlog同步过程

文章目录 binlog作用binlog同步过程主从同步架构图&#xff08;异步同步&#xff09; binlog作用 MySQL 的 binlog(二进制日志) 是一种记录数据库所有数据更改操作的日志&#xff0c;可以用于数据库备份、恢复、错误排查、数据同步等操作。binlog 是 MySQL 中的一个重要组件&a…

操作系统 --- 计算机系统引论

&#xff08;一&#xff09;操作系统的目的和作用 概念&#xff08;定义&#xff09; 操作系统 &#xff08; Operating System &#xff0c; OS &#xff09;是指控制和 管理 整个计算机系统的 硬件和软件 资源&#xff0c;并合理地组织调度计算机的工作和资源的分配&#…

基于SSM的网络游戏公司官方平台

末尾获取源码 开发语言&#xff1a;Java Java开发工具&#xff1a;JDK1.8 后端框架&#xff1a;SSM 前端&#xff1a;采用JSP技术开发 数据库&#xff1a;MySQL5.7和Navicat管理工具结合 服务器&#xff1a;Tomcat8.5 开发软件&#xff1a;IDEA / Eclipse 是否Maven项目&#x…

Swift学习笔记一(数组篇)

目录 0 绪论 1 数组的创建和初始化 2.数组遍历 2.1通过键值对遍历 2.2 通过forEach遍历 2.3 通过for in遍历 2.3.1 for in 搭配 enumerated 2.3.2 for in的另一种形式 2.3.2 for in 搭配 indices 2.4 通过Iterator遍历器遍历 3 数组的操作 3.1 contains 判断数组包含…

Redis之布隆过滤器(Bloom Filter)解读

目录 引进前言 隆过滤器定义 隆过滤器原理 布隆过滤器优缺点 布隆过滤器的使用场景 布谷鸟过滤器(了解) 引进前言 在实际开发中&#xff0c;会遇到很多要判断一个元素是否在某个集合中的业务场景&#xff0c;类似于垃圾邮件的识别&#xff0c;恶意ip地址的访问&#x…

NebulaGrap入门介绍和集群安装部署

长风破浪八千里&#xff0c;落日晚霞不回头。 ——大宁。 NebulaGrap——分布式图数据库 官方文档&#xff1a; ​ NebulaGraph Database手册 ​ 官方文档 介绍 简介&#xff1a; ​ NebulaGraph 一款开源、分布式图数据库&#xff0c;擅长处理超大规模数据集。 Nebula …

JS之闭包

一&#xff1a;什么是闭包 闭包是一个函数和其词法环境的组合 换个意思来说&#xff0c;闭包可以让开发者可以从函数内部访问到外部函数的作用域 在JS中&#xff0c;闭包会随着函数的创建而被同时创建 词法环境&#xff1a; 主要分两个对象 用于管理变量函数和作用域的关系 环…

《深入理解Java虚拟机》——Java内存区域与内存溢出异常

Java内存区域与内存溢出异常 运行时数据区域程序计数器Java虚拟机栈本地方法栈Java堆方法区运行时常量池直接内存 实例堆溢出栈溢出 运行时数据区域 根据《Java虚拟机规范的规定》&#xff0c;Java虚拟机所管理的内存将会包含已下架几个运行时数据区域。 程序计数器 在Java虚…

【漏洞复现】EnjoySCM存在文件上传漏洞

漏洞描述 EnjoySCM是一款适应于零售企业的供应链管理软件,主要为零售企业的供应商提供服务。EnjoySCM的目的是通过信息技术,实现供应商和零售企业的快速、高效、准确的信息沟通、管理信息交流。。 该系统存在任意文件上传漏洞,攻击者通过漏洞可以获取服务器的敏感信息。 …

k8s入门到实战--跨服务调用

service.png 背景 在做传统业务开发的时候&#xff0c;当我们的服务提供方有多个实例时&#xff0c;往往我们需要将对方的服务列表保存在本地&#xff0c;然后采用一定的算法进行调用&#xff1b;当服务提供方的列表变化时还得及时通知调用方。 student: url: - 192.168.1…

【STM32】STM32F4调用DSP库实现FFT运算

写在前面 最近在整理之前的stm32笔记&#xff0c;打算把一些有价值的笔记发到CSDN分享一下。 奎斯特定理 在进行模拟/数字信号的转换过程中&#xff0c;当采样频率F大于信号中最高频率 fmax 的 2 倍时(F>2*fmax)&#xff0c;采样之后的数字信号完整地保留了原始信号中的信…

高效采集模拟量模块数据方案

在现代工业自动化领域&#xff0c;模拟量采集是关键的环节之一。本文将详细介绍如何通过模拟量采集电压、电流和温度等数据&#xff0c;并利用上位机实现数据的获取和转化。同时&#xff0c;我们还将详细介绍模拟量采集上位机框架及其强大的功能&#xff0c;为企业实现高效的数…

华为认证系统学习大纲及课程

前言 任何学习过程都需要一个科学合理的学习路线&#xff0c;才能够有条不紊的完成我们的学习目标。华为认证网络工程师所需学习的内容纷繁复杂&#xff0c;难度较大&#xff0c;所以今天特别为大家整理了一个全面的华为认证网络工程师学习大纲及课程&#xff0c;帮大家理清思…

【DataV/echarts】vue中使用,修改地图和鼠标点击部分的背景色

引入&#xff1a;使用 DataV 引入地图的教程是参考别人的&#xff0c;主要介绍修改地图相关的样式&#xff1b; 引入地图 是参考别人的&#xff0c;这里自己再整理一遍&#xff0c;注意需要安装 5 版本以上的 echarts&#xff1b; DataV 网址&#xff1a;https://datav.aliyun.…

Unity Animation、Animator 的使用(超详细)

文章目录 1. 添加动画2. Animation2.1 制作界面2.2 制作好的 Animation 动画2.3 添加和使用事件 3. Animator3.1 制作界面3.2 一些参数解释3.3 动画参数 4. Animator中相关类、属性、API4.1 类4.2 属性4.3 API4.4 几个关键方法 5. 动画播放和暂停控制 1. 添加动画 选中待提添加…

Win10如何清理无效注册表

电脑中部分注册表文件其实是没有什么用的&#xff0c;如果用户不主动清理的话就会占用大量的内存空间&#xff0c;从而导致系统变得卡顿&#xff0c;那么Win10怎么清理无效注册表呢&#xff0c;下面小编就给大家详细介绍一下Win10清理无效注册表的方法&#xff0c;大家感兴趣的…

无涯教程-JavaScript - IMCOS函数

描述 IMCOS函数以x yi或x yj文本格式返回复数的余弦。 语法 IMCOS (inumber)争论 Argument描述Required/OptionalInumberA Complex Number for which you want the cosine.Required Notes Excel中的复数仅存储为文本。 当将格式为" a bi"或" a bj&quo…

门口通畅家运顺

每一次遇见&#xff0c;都是一个心愿&#xff0c;也许&#xff0c;前有未了的情缘&#xff0c;所以&#xff0c;此生才能得以见面&#xff0c;所有的遇见&#xff0c;一切都是最好的安排。前段时间&#xff0c;峰民再次故地重游&#xff0c;去到了呼伦比尔海拉尔区为预约客户来…