1. 锁介绍

  java中锁是个很重要的概念,当然这里的前提是你会涉及并发编程。

  除了语言提供的锁关键字 synchronized和volatile之外,jdk还有其他多种实用的锁。

  不过这些锁大多都是基于AQS队列同步器。ReadWriteLock 读写锁就是其中一个。

  读写锁的含义是,将读锁与写锁分开对待,读锁可以任意个一起读,因为读并不涉及数据变更,而遇到写锁后,所有后续的读写都将被阻塞。这特性有什么用呢?比如我们有一个缓存,我们可以用它来提高访问速度,但是当数据变更时,怎样能保证能读到准确的数据?

  在没有读写锁之前,我们可以使用wait/notify机制,我们可以以写锁作为一个同步介质,当写锁被占用时,读只能等待,写操作完成后,通知所有读继续。这看起来不那么好实现!

  当有了读写锁后,我们就不需要这么麻烦了,只需要读操作使用读锁,写操作获取写锁操作。大家可能会想,既然都要获取锁,那和其他锁有什么差别呢,一般看到锁咱们都会想到串行,阻塞。但其实读写锁不是这样的。看起来你是每次都获取读锁,但其实单纯的读锁并不会阻塞线程,所以同样是并行无阻,读锁只有在一种情况下会阻塞,那就是写锁被某线程占用时。因为写锁被占用则意味着,数据可能马上发生变化,如果此再允许读操作任意进行的话,多半可能读到写了一半或者是老数据,而这简直太糟了。而写锁则只每次都会真正进行后续操作的阻塞动作,使写操作保证强一致性。

  好了,以上就是咱们从概念上来理解读写锁。

  而实际上呢?ReadWriteLock只是一个接口,而其实现则可能是n多的。我们就以jdk实现的 ReentrantReadWriteLock 为契机,看一下读写锁的实现吧。

  在介绍 ReetrantReadWriteLock 之前,我们要先简单说下 ReentrantLock 重入锁,从字面意思理解,就是可重新进入的锁。那么,到底是什么意思呢?我们想一下,如果我们有2个资源锁可用,那么,如果我在本线程上上锁两次,是不是资源就没有了呢,那第三次进行锁获取的时候,是不是就把自己给锁死了呢?想想应该是这样的,但是为啥平时咱们都遇不到这种情况呢?原因就在于可重入性。可重入的意思是说,如果当前线程进行多次加锁操作,那么无论如何它自己都是可以进入的。简单从实现来说就是,锁会排除当前线程,从而避免自身阻塞。这些需求看起来很理所当然,但是咱们自己实现的时候可能会因为场景不一样,从而不一定需要这种特性呢。syncronized也是一种重入锁。好了,说了这么多,还是没有看到 ReetrantLock是怎么实现的!

用个不恰当的图描绘下:(该锁是读写分离的,读多于写的场景能够在保证线程安全的同时提供尽可能大的并发能力)

2. 简单没获取

我们来看下源码就一目了然了。

        /**
* Fair version of tryAcquire
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
// 第一次进入获取到锁后,标记获得锁的线程,后续判定重入
setExclusiveOwnerThread(current);
return true;
}
}
// 重入锁判定,否则失败
else if (current == getExclusiveOwnerThread()) {
// 最多可重入 int 次
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}

  重入锁介绍完后,咱们可以安心的来说说 ReentrantReadWriteLock了。该读写锁也是一种可重入锁。它要实现的特性就是,读读锁无阻塞,写锁必阻塞(包括写读锁/写写锁),读写锁阻塞(需等待读锁释放后才能获取写锁从而保证无脏读)。

  从上面可以看出,读和写是两个锁,但是他们的状态却是互相关联的,那怎样设计其数据结构呢?用两个变量去推导往往不太可行,因为其本身就是锁,如果再用两个变量去判定锁状态,那么又如何保证变量自身的可靠性呢?ReentrantReadWriteLock 是通过一个状态变量来控制的,具体为 高16位保存读锁状态,低16位保存写锁状态,而在改变状态时,使用cas保证写入的可靠性。(其实这里可以看出,锁个数不应该超过16位即65536个,这种锁数量已经完全被忽略掉了)。有了数据结构,咱们再看下怎么控制读写互联。读锁的获取,写锁没被占用时,即低位为0时,高位大于0即可代表获取了读锁,所以,读锁是n个可用的。而写锁的获取,则要依赖高低位判定了,高位大于0,即代表还有读锁存在,不能进入,如果高位为0,也不一定可进入,低位不为0则代表有写锁在占用,所以只有高低位都为0时,写锁才可用。

  下面,来看下读写锁的具体实现!

3. 来个例子先:

public class ReadWriteLockTest {

    private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
/**
* 读锁
*/
private Lock r = reentrantReadWriteLock.readLock(); /**
* 写锁
*/
private Lock w = reentrantReadWriteLock.writeLock(); /**
* 执行线程池
*/
private ExecutorService executorService = Executors.newCachedThreadPool(); @Test
public void testReadLock() {
for (int i = 0; i < 10; i++) {
Thread readWorker = new ReadWorker();
executorService.submit(readWorker);
}
waitForExecutorFinish();
} @Test
public void testWriteLock() {
for (int i = 0; i < 10; i++) {
Thread writeWorker = new WriteWorker();
executorService.submit(writeWorker);
}
waitForExecutorFinish();
} @Test
public void testReadWriteLock() {
for (int i = 0; i < 10; i++) {
Thread readWorker = new ReadWorker();
Thread writeWorker = new WriteWorker();
executorService.submit(readWorker);
executorService.submit(writeWorker);
}
waitForExecutorFinish();
} /**
* 线程模拟完成后,关闭线程池
*/
private void waitForExecutorFinish() {
executorService.shutdown();
try {
executorService.awaitTermination(100, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
} private final class ReadWorker extends Thread {
@Override
public void run() {
r.lock();
try {
SleepUtils.second(1);
System.out.println(System.currentTimeMillis() + ": " + Thread.currentThread().getName() + " reading...");
SleepUtils.second(1);
}
finally {
r.unlock();
}
}
} private final class WriteWorker extends Thread {
@Override
public void run() {
w.lock();
try {
SleepUtils.second(1);
System.out.println(System.currentTimeMillis() + ": " + Thread.currentThread().getName() + " writing...");
SleepUtils.second(1);
}
finally {
w.unlock();
}
}
} }

  可以看到 testReadLock(), 无阻塞,立即完成10个读任务!

  而 testWriteLock(),则是全部阻塞执行,20秒完成串行10个任务!

  而 testReadWriteLock(), 则是 读锁与写锁交替执行,在执行写锁时,所有锁等待,在执行读锁时,可能存在多个锁同时运行!执行结果样例如下:

: pool--thread- reading...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- reading...
: pool--thread- reading...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- reading...
: pool--thread- reading...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- writing...
: pool--thread- reading...
: pool--thread- reading...
: pool--thread- reading...
: pool--thread- reading...
: pool--thread- reading...

  ok, 现象已经展示了,是时候透过现象看本质了!

4. 读锁的获取过程 r.lock(), 其实现为 ReadLock!

        public void lock() {
// 调用 AQS 的 acquireShared() 方法,进行统一调度
sync.acquireShared(1);
}
// AQS 获取共享读锁
public final void acquireShared(int arg) {
// 调用 ReentrantReadWriteLock.Sync.tryAcquireShared(), 定义锁获取方式
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
} // 获取读锁,unused 传参未使用,直接使用内置的高位加1方式处理
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
Thread current = Thread.currentThread();
int c = getState();
// 写锁使用中,则直接获取失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
// 读锁任意获取,除了超过最大限制
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 对读锁阻塞情况,进行处理
return fullTryAcquireShared(current);
} // 获取低位数,即写锁状态值
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
// 获取高位数,即读锁状态值
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
} /**
* Full version of acquire for reads, that handles CAS misses
* and reentrant reads not dealt with in tryAcquireShared.
*/
final int fullTryAcquireShared(Thread current) {
/*
* This code is in part redundant with that in
* tryAcquireShared but is simpler overall by not
* complicating tryAcquireShared with interactions between
* retries and lazily reading hold counts.
*/
HoldCounter rh = null;
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current)
return -1;
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) {
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
} else {
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 验证通过,cas更新锁状态,使用 SHARED_UNIT 进行高位加1
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}

  以上是获取读锁的过程,其实际控制很简单,只是多了很多的状态统计,所以看起来复杂!

5. 下面,来看写锁的获取过程,WriteLock.lock()

        public void lock() {
// AQS获取独占锁
sync.acquire(1);
} // AQS 锁调度
public final void acquire(int arg) {
// 如果获取锁失败,则加入到等待队列中
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
} // ReentrantReadWriteLock.Sync.tryAcquire(), 写锁获取过程
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
// 如果是0,则说明不存在读写锁,直接成功
// 否则分有读锁和有写锁两种情况判断
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 存在读锁,或者不是当前线程(重入),则直接失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
// cas 更新 state
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
} /**
* Creates and enqueues node for current thread and given mode.
*
* @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
* @return the new node
*/
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
} // AQS 的锁入队列操,从队列中进行锁获取,如果获取失败,则产线一个中断标志
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 这里是公平锁的实现方式,只会从队列头获取锁
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 阻塞判定,响应中断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

  ok, 读写锁的获取已经完成,再来看一下释放的过程!

5. 读锁的释放 ReadLock.unlock()

        public void unlock() {
// AQS 的释放控制
sync.releaseShared(1);
} // AQS 释放锁
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
// ReentrantReadWriteLock.Sync.tryReleaseShared() 自定义释放
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
// cas更新状态,每次减1,直到为0,锁才算真正释放
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
} /**
* Release action for shared mode -- signals successor and ensures
* propagation. (Note: For exclusive mode, release just amounts
* to calling unparkSuccessor of head if it needs signal.)
*/
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}

6. 读锁的释放, WriteLock.unlock()

        public void unlock() {
// AQS 释放控制
sync.release(1);
}
// AQS
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
// 释放锁
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
// Sync.tryRelease()
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
// 如果写锁状态为0,则意味着当前线程完全释放锁,将 owner 线各设置为null
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
} /**
* Wakes up node's successor, if one exists.
*
* @param node the node
*/
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); /*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 调用 LockSupport 释放锁
if (s != null)
LockSupport.unpark(s.thread);
}

7. 锁降级

锁降级是指把写锁降级为读锁。如果当前线程,然后将其释放,最后再获取读锁,最后再获取读锁,这种不能称为锁降级!

锁降级是指把持住当前的写锁,再获取到读锁,随后释放写锁的过程;

锁降级的两个重要问题:

1. 为什么拥有了写锁,还要去再获取读锁?

2. 既然已经被写锁占有了,还能获取读锁吗?

回答完上面两个问题,才算真正明白锁降级的意义所在!

1. 再次想获取读锁的目的在于,读和写模块是分开的,而更新操作则可能在读的时候触发的。比如在读的时候发现数据过期了,这时就要调用写操作,而此时读锁又不能释放,所以需要在安全的情况下,释放和重新获取读锁;

2. 在写锁已经被获取的情况下,当前线程的读锁是可重入的,所以读锁对当前线程是开放的。而且,当前线程重新获取读锁后,其他线程的写锁将会被延迟获取,从而更高效地保证了当前线程的运行效率;

  综上,读写锁的简要解析就算完成了。 其主要使用 AQS 的基础组件,进行锁调度! 使用CAS进行状态的安全设置! 而锁的阻塞,则是使用 LockSupport 工具组件进行实际阻塞!

ReentrantReadWriteLock 读写锁解析的更多相关文章

  1. Java并发包源码学习系列:ReentrantReadWriteLock读写锁解析

    目录 ReadWriteLock读写锁概述 读写锁案例 ReentrantReadWriteLock架构总览 Sync重要字段及内部类表示 写锁的获取 void lock() boolean writ ...

  2. ReentrantReadWriteLock读写锁的使用

    Lock比传统线程模型中的synchronized方式更加面向对象,与生活中的锁类似,锁本身也应该是一个对象.两个线程执行的代码片段要实现同步互斥的效果,它们必须用同一个Lock对象. 读写锁:分为读 ...

  3. ReentrantReadWriteLock读写锁的使用2

    本文可作为传智播客<张孝祥-Java多线程与并发库高级应用>的学习笔记. 这一节我们做一个缓存系统. 在读本节前 请先阅读 ReentrantReadWriteLock读写锁的使用1 第一 ...

  4. 锁对象-Lock: 同步问题更完美的处理方式 (ReentrantReadWriteLock读写锁的使用/源码分析)

    Lock是java.util.concurrent.locks包下的接口,Lock 实现提供了比使用synchronized 方法和语句可获得的更广泛的锁定操作,它能以更优雅的方式处理线程同步问题,我 ...

  5. ReentrantReadWriteLock读写锁简单原理案例证明

    ReentrantReadWriteLock存在原因? 我们知道List的实现类ArrayList,LinkedList都是非线程安全的,Vector类通过用synchronized修饰方法保证了Li ...

  6. ReentrantReadWriteLock读写锁详解

    一.读写锁简介 现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁.在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源:但是如果一个线 ...

  7. java多线程:并发包中ReentrantReadWriteLock读写锁的锁降级模板

    写锁降级为读锁,但读锁不可升级或降级为写锁. 锁降级是为了让当前线程感知到数据的变化. //读写锁 private ReentrantReadWriteLock lock=new ReentrantR ...

  8. java中ReentrantReadWriteLock读写锁的使用

    Lock比传统线程模型中的synchronized方式更加面向对象,与生活中的锁类似,锁本身也应该是一个对象.两个线程执行的代码片段要实现同步互斥的效果,它们必须用同一个Lock对象. 读写锁:分为读 ...

  9. java多线程:ReentrantReadWriteLock读写锁使用

    Lock比传统的线程模型synchronized更多的面向对象的方式.锁和生活似,应该是一个对象.两个线程运行的代码片段要实现同步相互排斥的效果.它们必须用同一个Lock对象. 读写锁:分为读锁和写锁 ...

随机推荐

  1. 机器学习之支持向量机(SVM)学习笔记

    支持向量机是一种二分类算法,算法目的是找到一个最佳超平面将属于不同种类的数据分隔开.当有新数据输入时,判断该数据在超平面的哪一侧,继而决定其类别. 具体实现思路: 训练过程即找到最佳的分隔超平面的过程 ...

  2. Image 图片

    随机矩阵画图 这一节我们讲解怎样在matplotlib中打印出图像.这里打印出的是纯粹的数字,而非自然图像.下面用 3x3 的 2D-array 来表示点的颜色,每一个点就是一个pixel. impo ...

  3. python11 装饰器与闭包

    一.装饰器定义 本质:一种函数,为其他函数增加新功能 原则: 1.不修改被修饰函数的源代码 2.不修改被修饰函数的调用方式 需要技能:装饰器=高阶函数+函数嵌套+闭包 二.高阶函数 定义:函数接收的参 ...

  4. Windows Server 2012 配置远程桌面帐户允许多用户同时登录

    网上找了很多关于设置远程桌面最大连接数的文章,大都是说先要到控制面板的管理工具中设置远程桌面会话主机等,大体和我之前的文章<设置WINDOWS SERVER 2008修改远程桌面连接数>里 ...

  5. 《Java并发编程的艺术》Java并发机制的底层实现原理(二)

    Java并发机制的底层实现原理 1.volatile volatile相当于轻量级的synchronized,在并发编程中保证数据的可见性,使用 valotile 修饰的变量,其内存模型会增加一个 L ...

  6. Vue+Webpack构建去哪儿APP_一.开发前准备

    一.开发前准备 1.node环境搭建 去node.js官网下载长期支持版本的node,采用全局安装,安装方式自行百度 网址:https://nodejs.org/zh-cn/ 安装后在cmd命令行运行 ...

  7. 【NIFI】 Apache NiFI 使用技巧

    本章介绍NIFI组件的使用. 主要有:Nginx反向代理NIFI,配置SSLContextService Nginx反向代理NIFI 使用nginx反向代理NIFI配置如下 upstream nifi ...

  8. XBee PRO 900HP远距离无线模块

    XBee PRO S3B也称为XBee-900HP无线模块,它是一款工作在频段900~928MHz之间,基于FHSS跳频技术的远距离无线数传电台核心模块.常用型号如下: 类别 型号 开发套件 XKB9 ...

  9. 导出mysql的表格内容到txt文件

    操作流程: $ mysql -uroot -p mysql> use foo; mysql> select * from userinfo into outfile '/var/lib/m ...

  10. virtualenv虚拟环境

    1.你听过虚拟环境么? 虚拟:即不真实 环境:即周围的条件 那么到底什么事虚拟环境呢 2.虚拟环境 它是一个虚拟化,从电脑独立开辟出来的环境.通俗的来讲,虚拟环境就是借助虚拟机docker来把一部分内 ...