并发编程ReentrantReadWriteLock 和 StampLock详解

news/2024/5/20 10:19:53 标签: java, JUC, 并发, , 源码分析

并发编程ReentrantReadWriteLock 和 StampLock详解

  • 1. 为什么需要读写
    • 1.1 读写介绍
  • 2. 如何设计一把读写
    • 重点:如何保证读写互斥?
      • 如何维护两个状态?
      • 如果要实现可重入,如何做?
  • 3. ReentrantReadWriteLock介绍
    • 3.2 ReentrantReadWriteLock类结构
      • 3.2.1 如何使用读写
        • 注意事项
      • 3.2.2 应用场景
        • 读写在缓存中的应用
    • 3.3 降级
      • 3.3.1降级的使用示例
    • 3.4 ReentrantReadWriteLock 底层思路
      • 3.4.1 读写状态的设计
      • 3.4.2 Hold Counter 计数器
    • 3.5 ReentrantReadWriteLock存在的问题
  • 4. StampedLock的使用
      • 4.1 Stamp Lock三种访问模式
      • 4.2 思考
        • 4.2.1 为何 StampedLock 性比 ReentrantReadWriteLock 好?
        • 4.2.2 思考:允许多个乐观读和一个写线程同时进入临界资源操作,那读取的数据可能是错的怎么办?
      • 4.3 演示乐观读
      • 4.4 在缓存中的应用
      • 4.5 使用场景和注意事项
  • 5. 总结

1. 为什么需要读写

读/读不存在线程安全问题。写/读,写/写操作存在线程安全问题的

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁(读多写少)。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源(读读共享);但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写操作了(读写,写写互斥)。

思考:针对这种场景,有没有比ReentrantLock更好的方案?

读写: 读多写少

1.1 读写介绍

读写ReadWriteLock,顾名思义一把分为读与写两部分,读允许多个线程同时获得,因为读操作本身是线程安全的。而写是互斥,不允许多个线程同时获得写。并且读与写操作也是互斥的。读写适合多读少写的业务场景。

2. 如何设计一把读写

读写: 多线程的读,共享,写互斥。适合读多写少的情况

依赖:

  • AQS
  • 互斥: tryAcquire() tryRelease()
  • 共享:tryAcquireShared() tryReleaseShared()

其实共享和semaphore机制底层类似

java">        protected int tryAcquireShared(int acquires) {
            return nonfairTryAcquireShared(acquires);
        }
	    final int nonfairTryAcquireShared(int acquires) {
            for (;;) {
                int available = getState();
                int remaining = available - acquires;
                if (remaining < 0 ||
                    compareAndSetState(available, remaining))
                    return remaining;
            }
        }
        protected final boolean tryReleaseShared(int releases) {
            for (;;) {
                int current = getState();
                int next = current + releases;
                if (next < current) // overflow
                    throw new Error("Maximum permit count exceeded");
                if (compareAndSetState(current, next))
                    return true;
            }
        }

重点:如何保证读写互斥?

如何维护两个状态?

读状态和写状态

  • AQS只有一个state状态,如果实现,可以使用 高、低位实现
    • int 类型占四个字节,共32位,可以使用高16位和低16位。
    • 若低16位代表写,高16位代表读,如果低16位>0,那么一定有写,可以通过移位实现。

如果要实现可重入,如何做?

独占,只会有一个线程占用

允许多个线程,那么允许多个线程重入,只依靠低16位,无法做到多线程可重入中的统计重入次数,这个统计重入次数,依赖ThreadLocal(线程私有的)实现

java">       /*
         * 读取与写入计数提取常量和函数。状态在逻辑上分为两个无符号短路:下一个表示独占(写入器)保持计数,上一个表示共享(读取器)保持计数。
         * Read vs write count extraction constants and functions.
         * Lock state is logically divided into two unsigned shorts:
         * The lower one representing the exclusive (writer) lock hold count,
         * and the upper the shared (reader) hold count.
         */

        static final int SHARED_SHIFT   = 16; //代表移位的位数,
        static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
        static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

待复习:可重入

3. ReentrantReadWriteLock介绍

针对这种场景,JAVA的并发包提供了读写ReentrantReadWriteLock,它内部,维护了一对相关的,一个用于只读操作,称为读;一个用于写入操作,称为写,描述如下:

线程进入读的前提条件:

  • 没有其他线程的写
  • 没有写请求或者有写请求,但调用线程和持有的线程是同一个。

线程进入写的前提条件:

  • 没有其他线程的读
  • 没有其他线程的写

而读写有以下三个重要的特性:

  • 公平选择性:支持非公平(默认)和公平的获取方式,吞吐量还是非公平优于公平。
  • 可重入:读和写都支持线程重入。以读写线程为例:读线程获取读后,能够再次获取读。写线程在获取写之后能够再次获取写,同时也可以获取读
  • 降级:遵循获取写、再获取读最后释放写的次序,能够降级成为读

3.2 ReentrantReadWriteLock类结构

ReentrantReadWriteLock是可重入的读写实现类。在它内部,维护了一对相关的,一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读可以由多个 Reader 线程同时持有。也就是说,写是独占的,读是共享的。

3.2.1 如何使用读写

private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock r = readWriteLock.readLock();
private Lock w = readWriteLock.writeLock();

// 读操作上读
public Data get(String key) {
  r.lock();
  try { 
      // TODO 业务逻辑
  }finally { 
       r.unlock(); 
   }
}

// 写操作上写
public Data put(String key, Data value) {
  w.lock();
  try { 
      // TODO 业务逻辑
  }finally { 
       w.unlock(); 
   }
}

注意事项

  • 不支持条件变量
  • 重入时升级不支持:持有读的情况下去获取写,会导致获取永久等待
  • 重入时支持降级: 持有写的情况下可以去获取读

3.2.2 应用场景

以下是使用ReentrantReadWriteLock的常见场景:

  1. 读多写少:ReentrantReadWriteLock适用于读操作比写操作频繁的场景,因为它允许多个读线程同时访问共享数据,而写操作是独占的。
  2. 缓存:ReentrantReadWriteLock可以用于实现缓存,因为它可以有效地处理大量的读操作,同时保护缓存数据的一致性。

读写在缓存中的应用

java">public class Cache {
    static Map<String, Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock r = rwl.readLock();
    static Lock w = rwl.writeLock();

    // 获取一个key对应的value
    public static final Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }

    // 设置key对应的value,并返回旧的value
    public static final Object put(String key, Object value) {
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }

    // 清空所有的内容
    public static final void clear() {
        w.lock();
        try {
            map.clear();
        } finally {
            w.unlock();
        }
    }
}

上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写的读和写来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新 HashMap时必须提前获取写,当获取写后,其他线程对于读和写的获取均被阻塞,而 只有写被释放之后,其他读写操作才能继续。Cache使用读写提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式

3.3 降级

降级指的是写降级成为读。如果当前线程拥有写,然后将其释放,最后再获取读,这种分段完成的过程不能称之为降级。降级是指把持住(当前拥有的)写,再获取到读,随后释放(先前拥有的)写的过程降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。

3.3.1降级的使用示例

因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作。

java">private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
private volatile boolean update = false;

public void processData() {
    readLock.lock();
    if (!update) {
        // 必须先释放读
        readLock.unlock();
        // 降级从写获取到开始
        writeLock.lock();
        try {
            if (!update) {
                // TODO 准备数据的流程(略)  
                update = true;
            }
            readLock.lock();
        } finally {
            writeLock.unlock();
        }
        // 降级完成,写降级为读
    }
    try {
        //TODO  使用数据的流程(略)
    } finally {
        readLock.unlock();
    }
}

降级中读的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读而是直接释放写,假设此刻另一个线程(记作线程T)获取了写并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读,即遵循降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读之后,线程T才能获取写进行数据更新。

ReentrantReadWriteLock不支持升级(把持读、获取写,最后释放读的过程)。目的也是保证数据可见性,如果读已被多个线程获取,其中任意线程成功获取了写并更新了数据,则其更新对其他获取到读的线程是不可见的。

3.4 ReentrantReadWriteLock 底层思路

在这里插入图片描述

3.4.1 读写状态的设计

设计的精髓:用一个变量如何维护多种状态

在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示被一个线程重复获取的次数。但是,读写 ReentrantReadWriteLock 内部维护着一对读写,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写。

分割之后,读写是如何迅速确定读和写的状态呢?通过位运算。假如当前同步状态为S,那么:

  • 写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)。 当写状态加1,等于S+1.
  • 读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。当读状态加1,等于S+(1<<16)

根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读已被获取。

在这里插入图片描述

代码实现:java.util.concurrent.locks.ReentrantReadWriteLock.Sync
在这里插入图片描述

  • exclusiveCount(int c) 静态方法,获得持有写状态的的次数。
  • sharedCount(int c) 静态方法,获得持有读状态的的数量。不同于写,读可以同时被多个线程持有。而每个线程持有的读支持重入的特性,所以需要对每个线程持有的读的数量单独计数,这就需要用到 HoldCounter 计数器

3.4.2 Hold Counter 计数器

**读的内在机制其实就是一个共享。**一次共享的操作就相当于对Hold Counter 计数器的操作。获取共享,则该计数器 + 1,释放共享,该计数器 - 1。只有当线程获取共享后才能对共享进行释放、重入操作。
在这里插入图片描述

通过 ThreadLocalHoldCounter 类,HoldCounter 与线程进行绑定。HoldCounter 是绑定线程的一个计数器,而 ThreadLocalHoldCounter 则是线程绑定的 ThreadLocal。

  • HoldCounter是用来记录读重入数的对象
  • ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读的线程的其他线程的读重入数对象

3.5 ReentrantReadWriteLock存在的问题

如果我们深入分析ReentrantReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放后才能获取写,即读的过程中不允许写,这是一种悲观的读

为了进一步提升并发执行效率,Java 8引入了新的读写StampedLock

StampedLockReentrantReadWriteLock相比,改进之处在于:读的过程中也允许获取写后写入

在原先读写的基础上新增了一种叫**乐观读(Optimistic Reading)**的模式。该模式并不会加,所以不会阻塞线程,会有更高的吞吐量和更高的性能。

它的设计初衷是作为一个内部工具类,用于开发其他线程安全的组件,提升系统性能,并且编程模型也比ReentrantReadWriteLock 复杂,所以用不好就很容易出现死或者线程安全等莫名其妙的问题。

悲观

  • 任何操作都加

乐观

  • 不加,加版本号

4. StampedLock的使用

4.1 Stamp Lock三种访问模式

  • Writing(独占写:writeLock 方法会使线程阻塞等待独占访问,可类比ReentrantReadWriteLock 的写模式,同一时刻有且只有一个写线程获取资源;
  • Reading(悲观读:readLock方法,允许多个线程同时获取悲观读,悲观读与独占写互斥,与乐观读共享。
  • Optimistic Reading(乐观读):这里需要注意了,乐观读并没有加不会有 CAS 机制并且没有阻塞线程。仅当当前未处于 Writing 模式 tryOptimisticRead 才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有出现写模式线程获取,则在方法validate返回 true ,允许多个线程获取乐观读以及读,同时允许一个写线程获取写

在使用乐观读的时候一定要按照固定模板编写,否则很容易出 bug,我们总结下乐观读编程模型的模板:

java">public void optimisticRead() {
    // 1. 非阻塞乐观读模式获取版本信息
    long stamp = lock.tryOptimisticRead();
    // 2. 拷贝共享数据到线程本地栈中
    copyVaraibale2ThreadMemory();
    // 3. 校验乐观读模式读取的数据是否被修改过
    if (!lock.validate(stamp)) {
        // 3.1 校验未通过,上读
        stamp = lock.readLock();
        try {
            // 3.2 拷贝共享变量数据到局部变量
            copyVaraibale2ThreadMemory();
        } finally {
            // 释放读
            lock.unlockRead(stamp);
        }
    }
    // 3.3 校验通过,使用线程本地栈的数据进行逻辑操作
    useThreadMemoryVarables();
}

4.2 思考

4.2.1 为何 StampedLock 性比 ReentrantReadWriteLock 好?

关键在于StampedLock 提供的乐观读。ReentrantReadWriteLock 支持多个线程同时获取读,但是当多个线程同时读的时候,所有的写线程都是阻塞的。StampedLock 的乐观读允许一个写线程获取写,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写,减少了线程饥饿的问题,吞吐量大大提高。

4.2.2 思考:允许多个乐观读和一个写线程同时进入临界资源操作,那读取的数据可能是错的怎么办?

乐观读不能保证读取到的数据是最新的,所以将数据读取到局部变量的时候需要通过lock.validate(stamp)

校验是否被写线程修改过,若是修改过则需要上悲观读,再重新读取数据到局部变量。

4.3 演示乐观读

java">public class StampedLockTest{

    public static void main(String[] args) throws InterruptedException {
        Point point = new Point();

        //第一次移动x,y
        new Thread(()-> point.move(100,200)).start();
        Thread.sleep(100);
        new Thread(()-> point.distanceFromOrigin()).start();
        Thread.sleep(500);
        //第二次移动x,y
        new Thread(()-> point.move(300,400)).start();

    }
}

@Slf4j
class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        // 获取写
        long stamp = stampedLock.writeLock();
        log.debug("获取到writeLock");
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            // 释放写
            stampedLock.unlockWrite(stamp);
            log.debug("释放writeLock");
        }
    }

    public double distanceFromOrigin() {
        // 获得一个乐观读
        long stamp = stampedLock.tryOptimisticRead();
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentX = x;
        log.debug("第1次读,x:{},y:{},currentX:{}",
                x,y,currentX);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        double currentY = y;
        log.debug("第2次读,x:{},y:{},currentX:{},currentY:{}",
                x,y,currentX,currentY);

        // 检查乐观读后是否有其他写发生
        if (!stampedLock.validate(stamp)) {
            // 获取一个悲观读
            stamp = stampedLock.readLock();
            try {
                currentX = x;
                currentY = y;

                log.debug("最终结果,x:{},y:{},currentX:{},currentY:{}",
                        x,y,currentX,currentY);
            } finally {
                // 释放悲观读
                stampedLock.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

}

4.4 在缓存中的应用

将用户id与用户名数据保存在 共享变量 idMap 中,并且提供 put 方法添加数据、get 方法获取数据、以及 getIfNotExist 先从 map 中获取数据,若没有则模拟从数据库查询数据并放到 map 中。

java">public class CacheStampedLock {
    /**
     * 共享变量数据
     */
    private final Map<Integer, String> idMap = new HashMap<>();
    private final StampedLock lock = new StampedLock();


    /**
     * 添加数据,独占模式
     */
    public void put(Integer key, String value) {
        long stamp = lock.writeLock();
        try {
            idMap.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    /**
     * 读取数据,只读方法
     */
    public String get(Integer key) {
        // 1. 尝试通过乐观读模式读取数据,非阻塞
        long stamp = lock.tryOptimisticRead();
        // 2. 读取数据到当前线程栈
        String currentValue = idMap.get(key);
        // 3. 校验是否被其他线程修改过,true 表示未修改,否则需要加悲观读
        if (!lock.validate(stamp)) {
            // 4. 上悲观读,并重新读取数据到当前线程局部变量
            stamp = lock.readLock();
            try {
                currentValue = idMap.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        // 5. 若校验通过,则直接返回数据
        return currentValue;
    }

    /**
     * 如果数据不存在则从数据库读取添加到 map 中,升级运用
     * @param key
     * @return
     */
    public String getIfNotExist(Integer key) {
        // 获取读,也可以直接调用 get 方法使用乐观读
        long stamp = lock.readLock();
        String currentValue = idMap.get(key);
        // 缓存为空则尝试上写从数据库读取数据并写入缓存
        try {
            while (Objects.isNull(currentValue)) {
                // 尝试升级写
                long wl = lock.tryConvertToWriteLock(stamp);
                // 不为 0 升级写成功
                if (wl != 0L) {
                    stamp = wl;
                    // 模拟从数据库读取数据, 写入缓存中
                    currentValue = "query db";
                    idMap.put(key, currentValue);
                    break;
                } else {
                    // 升级失败,释放之前加的读并上写,通过循环再试
                    lock.unlockRead(stamp);
                    stamp = lock.writeLock();
                }
            }
        } finally {
            // 释放最后加的
            lock.unlock(stamp);
        }
        return currentValue;
    }

}

上面的使用例子中,需要引起注意的是 get()和 getIfNotExist() 方法,第一个使用了乐观读,使得读写可以并发执行,第二个则是使用了读转换成写的编程模型,先查询缓存,当不存在的时候从数据库读取数据并添加到缓存中。

4.5 使用场景和注意事项

对于读多写少的高并发场景 StampedLock的性能很好,通过乐观读模式很好的解决了写线程“饥饿”的问题,我们可以使用StampedLock 来代替ReentrantReadWriteLock ,但是需要注意的是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。

  • StampedLock 写是不可重入的,如果当前线程已经获取了写,再次重复获取的话就会死,使用过程中一定要注意;
  • 悲观读、写都不支持条件变量 Conditon ,当需要这个特性的时候需要注意;
  • 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读 readLockInterruptibly() 和写 writeLockInterruptibly()。

5. 总结

  1. ReentrantReadWriteLock(可重入读写):
    • ReentrantReadWriteLock提供了一种更灵活的读写机制,允许多个线程同时进行读操作,但只允许一个线程进行写操作。
    • 它采用了"悲观读取、悲观写入"的策略,即在写操作期间禁止其他线程进行读和写操作,以保证数据的一致性。
    • 具有可重入性,同一个线程可以多次获取读或写
    • 支持公平和非公平两种读写分配策略,通过构造函数来指定。
  2. StampedLock(标记):
    • StampedLock引入了乐观读的概念,并结合了读写的特点。
    • 对于读操作,可以尝试乐观(tryOptimisticRead),并在最后验证之前是否发生了写入操作。
    • 如果没有发生写入操作,读操作就可以继续执行;如果发生了写入操作,需要重新获取读
    • 对于写操作,会阻塞其他所有的读和写操作,直到写操作完成。
    • StampedLock相对于ReentrantReadWriteLock,在高并发读的情况下,性能更好。

ReentrantReadWriteLock适用于读操作频繁、写操作较少的场景,可以提供更高的并发度和吞吐量。

StampedLock适用于读操作非常频繁、写操作较少的场景,通过乐观读的方式提高并发性能。选择使用哪种取决于具体的应用场景和需求。


http://www.niftyadmin.cn/n/1792404.html

相关文章

python8个程序语言_TIOBE 8 月编程语言榜:Python 差点拿下第 3 名

TIOBE 发布了 8 月份的编程语言排行榜&#xff0c;前四名依然不变&#xff0c;分别是 Java、C、C 与 Python&#xff0c;其中值得关注的是 Python 以 6.992% 的占比逼近 7.471% 的 C&#xff0c;另外 C 指数继续保持增长。前 20 名如下&#xff1a;需要注意的是&#xff0c;SQL…

Response.Flush()

Response.BufferTrue就是在缓存网页 访问网站一般是程序直接输出网页结果&#xff0c;或从缓存中读取网页结果2种方式。两种方式在速度上是有差异的 设置 Response.Buffer True 时直到程序执行完或者遇到<% Response.Flush %>或<% Response.End %>语句&#xff0c…

注册中心选型

前言 服务注册中心本质上是为了解耦服务提供者和服务消费者。对于任何一个微服务&#xff0c;原则上都应存在或者支持多个提供者&#xff0c;这是由微服务的分布式属性决定的。更进一步&#xff0c;为了支持弹性扩缩容特性&#xff0c;一个微服务的提供者的数量和分布往往是动态…

MySQL数据库(九) 一一 处理重复和SQL注入

应该是后天写转载于:https://www.cnblogs.com/enjong/articles/8562194.html

学习日常笔记day14自定义标签

1自定义标签 1.1第一个自定义标签开发步骤 1&#xff09;编写一个普通的java类&#xff0c;继承SimpleTagSupport类&#xff0c;叫标签处理器类 1 /**2 * 标签处理器类3 * author APPle4 * 1&#xff09;继承SimpleTagSupport5 *6 */7 public class ShowIpTag extends Sim…

matlab @函数_matlab画图篇:一元函数的绘图

点击上方“蓝字”关注我们上次小楼同学为大家介绍了如何画图论中的图&#xff0c;有同学留言问如何画函数图像。因此&#xff0c;小楼同学为大家准备了matlab画图篇&#xff0c;希望能够帮助大家学习。画图当然要从简单的开启&#xff0c;因此小楼同学先为大家介绍一元函数的作…

Vault架构

vault架构 Vault功能 密码的安全存储。Vault把密码以加密的形式存储起来&#xff0c;黑客拿到了密文&#xff0c;也很难解密。这个是最基本的需求。 动态密码。一般我们创建一个db&#xff0c;会创建一个新的role和db关联&#xff0c;为此要创建密码。这样的密码越复杂越好&a…

openstack queens版本安装-搭建你自己的企业私有云迎娶你的王后

openstack 安装步骤&#xff1a;环境准备&#xff1a;系统&#xff1a;centos7 x86_64controller 2c6g40g 192.168.147.50 可以nat上网compute 1c4g40g 192.168.147.60 可以nat上网neutron 1c2g20g 192.168.147.70 可以nat上网 关闭selinux:cat /etc/sysconfig/selinux SELINUX…