14 | Lock和Condition(上):隐藏在并发包中的管程

并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题

再造管程的理由

Java 语言本身提供的 synchronized 也是管程的一种实现,既然 Java 从语言层面已经实现了管程了,那为什么还要在 SDK 里提供另外一种实现呢?

解决死锁问题的时候,提出了一个破坏不可抢占条件方案,但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。(synchronized 不会对中断进行响应,Lock会对中断进行响应)

我们希望的是:

对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

重新设计一把互斥锁去解决这个问题,有三种方案:

  1. 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  2. 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

这三种方案可以全面弥补 synchronized 的问题,体现在 API 上,就是 Lock 接口的三个方法。详情如下:

// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();

除了支持类似 synchronized 隐式加锁的 lock() 方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利

如何保证可见性

Java 里多线程的可见性是通过 Happens-Before 规则保证的,而 synchronized 之所以能够保证可见性,也是因为有一条 synchronized 相关的规则:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁

Java SDK 里面锁利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值

什么是可重入锁

可重入锁,顾名思义,指的是线程可以重复获取同一把锁, ReentrantLock,这个翻译过来叫可重入锁

可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。可重入函数是线程安全的。

公平锁与非公平锁

锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒

用锁的最佳实践

三个用锁的最佳实践:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远不在调用其他对象的方法时加锁

下面代码不会出现死锁,但可能会因为资源谦让,出现活锁问题。解决办法就是加个随机重试时间避免活锁

class Account {private int balance;private final Lock lock = new ReentrantLock();// 转账void transfer(Account tar, int amt){while (true) {if(this.lock.tryLock()) {try {if (tar.lock.tryLock()) {try {this.balance -= amt;tar.balance += amt;} finally {tar.lock.unlock();}}//if} finally {this.lock.unlock();}}//if}//while}//transfer
}

15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?

Java SDK 并发包里的Condition 实现了管程模型里面的条件变量,Java 语言内置的管程里只有一个条件变量,而 Lock&Condition 实现的管程是支持多个条件变量的,这是二者的一个重要区别。在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易。

利用两个条件变量快速实现阻塞队列

一个阻塞队列,需要两个条件变量,一个条件变量是队列不空(空队列不允许出队),另一个条件变量是队列不满(队列已满不允许入队)。

需要注意,Java SDK 并发包里的 Lock 和 Condition 不过就是管程的一种实现。Lock 和 Condition 实现的管程,线程等待和通知需要调用 await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。但是不一样的是,Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用。

同步与异步

通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步

比如在代码里,有一个计算圆周率小数点后 100 万位的方法pai1M(),这个方法可能需要执行俩礼拜,如果调用pai1M()之后,线程一直等着计算结果,等俩礼拜之后结果返回,才可以继续向下执行 ,这个属于同步;如果调用pai1M()之后,线程不用等待计算结果,立刻就可以向下执行,这个就属于异步。

Java 代码默认是同步。如果你想让你的程序支持异步,可以通过下面两种方式来实现:

  1. 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用
  2. 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法我们一般称为异步方法

17 | ReadWriteLock:快速实现一个完备的缓存

管程和信号量这两个同步原语在 Java 语言中的实现,理论上用这两个同步原语中任何一个都可以解决所有的并发问题。Java SDK 并发包里还有很多其他的工具类的原因是:分场景优化性能,提升易用性

一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。

针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock。

什么是读写锁

读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则

  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作,同样,当有一个线程在读共享变量时,也不允许其他线程执行写操作。

读写锁的特点如下:

  1. 如果有线程读数据,则允许其它线程执行读操作,但不允许写操作。
  2. 如果有线程写数据,则其它线程都不允许读、写操作。

快速实现一个缓存

用 ReadWriteLock 快速实现一个通用的缓存工具类

在下面的代码中,我们声明了一个 Cache<K, V> 类,其中类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型。缓存的数据保存在 Cache 类内部的 HashMap 里面,HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock,通过名字你应该就能判断出来,它是支持可重入的。下面我们通过 rwl 创建了一把读锁和一把写锁。

Cache 这个工具类,我们提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法 put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是 try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。

class Cache<K,V> {final Map<K, V> m = new HashMap<>();final ReadWriteLock rwl = new ReentrantReadWriteLock();// 读锁final Lock r = rwl.readLock();// 写锁final Lock w = rwl.writeLock();// 读缓存V get(K key) {r.lock();try { return m.get(key); }finally { r.unlock(); }}// 写缓存V put(String key, Data v) {w.lock();try { return m.put(key, v); }finally { w.unlock(); }}
}

缓存数据初始化

缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。

如果源头数据的数据量不大,就可以采用一次性加载的方式:只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put() 方法就可以了。

如果源头数据量非常大,那么就需要按需加载:按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作

实现缓存的按需加载

文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 w.lock() 来获取写锁。

另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么要再次验证呢?

原因是在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题

class Cache<K,V> {final Map<K, V> m = new HashMap<>();final ReadWriteLock rwl = new ReentrantReadWriteLock();final Lock r = rwl.readLock();final Lock w = rwl.writeLock();V get(K key) {V v = null;// 读缓存r.lock();         ①try {v = m.get(key); ②} finally{r.unlock();     ③}// 缓存中存在,返回if(v != null) {   ④return v;}  // 缓存中不存在,查询数据库w.lock();         ⑤try {// 再次验证// 其他线程可能已经查询过数据库v = m.get(key); ⑥if(v == null){  ⑦// 查询数据库v= 省略代码无数m.put(key, v);}} finally{w.unlock();}return v; }
}

读写锁的升级与降级

先获取到读锁,然后在不释放读锁的情况下,去获取写锁,这就叫做锁的升级

ReadWriteLock 并不支持锁升级,锁的升级是不允许的。如果读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。

//错误demo,如果读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒
// 读缓存
r.lock();         ①
try {v = m.get(key); ②if (v == null) {w.lock();try {// 再次验证并更新缓存// 省略详细代码} finally{w.unlock();}}
} finally{r.unlock();     ③
}

不允许锁升级,但是允许锁降级

锁降级:在线程还持有写锁的情况下,申请获取读锁,是被允许的。

读写操作互斥是发生在不同线程之间,而锁降级时,持有写锁获取读锁是发生在同一个线程中

以下代码来源自 ReentrantReadWriteLock 的官方示例,略做了改动。你会发现在代码①处,获取读锁的时候线程还是持有写锁的,这种锁的降级是支持的。

class CachedData {Object data;volatile boolean cacheValid;final ReadWriteLock rwl = new ReentrantReadWriteLock();// 读锁  final Lock r = rwl.readLock();// 写锁final Lock w = rwl.writeLock();void processCachedData() {// 获取读锁r.lock();if (!cacheValid) {// 释放读锁,因为不允许读锁的升级r.unlock();// 获取写锁w.lock();try {// 再次检查状态  if (!cacheValid) {data = ...cacheValid = true;}// 释放写锁前,降级为读锁// 降级是可以的r.lock(); ①} finally {// 释放写锁w.unlock(); }}// 此处仍然持有读锁try {use(data);} finally {r.unlock();}}
}

读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

保证缓存数据和源头数据的一致性

用 ReadWriteLock 实现了一个简单的缓存,这个缓存虽然解决了缓存的初始化问题,但是没有解决缓存数据与源头数据的同步问题,这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。

当然也可以在源头数据发生变化时,快速反馈给缓存,但这个就要依赖具体的场景了。例如 MySQL 作为数据源头,可以通过近实时地解析 binlog 来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。另外,还有一些方案采取的是数据库和缓存的双写方案。

具体采用哪种方案,还是要看应用的场景。

18 | StampedLock:有没有比读写锁更快的锁?

Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好。

StampedLock 支持的三种锁模式

ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而 StampedLock 支持三种模式,分别是:写锁悲观读锁乐观读

其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。

final StampedLock sl = new StampedLock();// 获取 / 释放悲观读锁示意代码
long stamp = sl.readLock();
try {// 省略业务相关代码
} finally {sl.unlockRead(stamp);
}// 获取 / 释放写锁示意代码
long stamp = sl.writeLock();
try {// 省略业务相关代码
} finally {sl.unlockWrite(stamp);
}

StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞

注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。

下面这段代码是出自 Java SDK 官方示例,并略做了修改。在 distanceFromOrigin() 这个方法中,首先通过调用 tryOptimisticRead() 获取了一个 stamp,这里的 tryOptimisticRead() 就是我们前面提到的乐观读。之后将共享变量 x 和 y 读入方法的局部变量中,不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 x 和 y 读入方法局部变量时,x 和 y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的

class Point {private int x, y;final StampedLock sl = new StampedLock();// 计算到原点的距离  int distanceFromOrigin() {// 乐观读long stamp = sl.tryOptimisticRead();// 读入局部变量,// 读的过程数据可能被修改int curX = x, curY = y;// 判断执行读操作期间,// 是否存在写操作,如果存在,// 则 sl.validate 返回 falseif (!sl.validate(stamp)){// 升级为悲观读锁stamp = sl.readLock();try {curX = x;curY = y;} finally {// 释放悲观读锁sl.unlockRead(stamp);}}return Math.sqrt(curX * curX + curY * curY);}
}

在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x 和 y 的正确性和一致性),而循环读会浪费大量的 CPU。升级为悲观读锁,代码简练且不易出错,建议你在具体实践时也采用这样的方法。

StampedLock 的乐观读和数据库的乐观锁有异曲同工之妙。数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp。

StampedLock 使用注意事项

对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。

  1. StampedLock 不支持重入。StampedLock 在命名上并没有增加 Reentrant。
  2. StampedLock 的悲观读锁、写锁都不支持条件变量。
  3. 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。
  4. StampedLock 支持锁的降级(通过 tryConvertToReadLock() 方法实现)和升级(通过 tryConvertToWriteLock() 方法实现),但是建议你要慎重使用。在锁升降级成功后,这两个方法会返回新的stamped标记,在释放锁时需要传入新的stamped,而不是旧的

例如下面的代码中,线程 T1 获取写锁之后将自己阻塞,线程 T2 尝试获取悲观读锁,也会阻塞;如果此时调用线程 T2 的 interrupt() 方法来中断线程 T2 的话,你会发现线程 T2 所在 CPU 会飙升到 100%。

所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。

final StampedLock lock = new StampedLock();
Thread T1 = new Thread(()->{// 获取写锁lock.writeLock();// 永远阻塞在此处,不释放写锁LockSupport.park();
});
T1.start();
// 保证 T1 获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()->// 阻塞在悲观读锁lock.readLock()
);
T2.start();
// 保证 T2 阻塞在读锁
Thread.sleep(100);
// 中断线程 T2
// 会导致线程 T2 所在 CPU 飙升
T2.interrupt();
T2.join();

StampedLock模板

把 Java 官方示例精简后,形成下面的代码模板,建议在实际工作中尽量按照这个模板来使用 StampedLock。

StampedLock 读模板

final StampedLock sl = new StampedLock();// 乐观读
long stamp = sl.tryOptimisticRead();
// 读入方法局部变量
......
// 校验 stamp
if (!sl.validate(stamp)){// 升级为悲观读锁stamp = sl.readLock();try {// 读入方法局部变量.....} finally {// 释放悲观读锁sl.unlockRead(stamp);}
}
// 使用方法局部变量执行业务操作
......

StampedLock 写模板

long stamp = sl.writeLock();
try {// 写共享变量......
} finally {sl.unlockWrite(stamp);
}

附加

  • Happens-Before
  • https://www.cnblogs.com/ldws/p/11970087.html
  • https://www.cnblogs.com/ldws/p/11760745.html
  • 17-保证缓存数据和源头数据的一致性-mysql binlog
  • 18-stampedLock阻塞调用interrupt引起cpu飙高

java并发编程实战(二)-并发工具类相关推荐

  1. c++并发编程实战_Java 并发编程实战:JAVA中断线程几种基本方法

    一个多线程Java程序,只有当其全部线程执行结束时(更具体地说,是所有非守护线程结束或者某个线程调用system.exit()方法的时候) ,才会结束运行.有时,为了终止程序或者取消一个线程对象所执行 ...

  2. java并发编程中常用的工具类 Executor

    /***************************************************  * TODO: description .  * @author: gao_chun  * ...

  3. Java并发编程实战笔记—— 并发编程1

    1.如何创建并运行java线程 创建一个线程可以继承java的Thread类,或者实现Runnabe接口. public class thread {static class MyThread1 ex ...

  4. Java 网络编程(二) 两类传输协议:TCP UDP

    两类传输协议:TCP,UDP TCP TCP是Transfer Control Protocol(传输控制协议)的简称,是一种面向连接的保证可靠传输的协议. 在TCP/IP协议中, IP层主要负责网络 ...

  5. java并发编程实战(二)

    java并发编程中常常会用到两种容器来存放一些数据,这些数据需要保证能在多线程下正常访问.常见的容器分为两类:同步容器和并发容器.在java并发编程实战一书中的第五章也有讲解. 什么是同步容器以及优劣 ...

  6. Java并发编程实战_不愧是领军人物!这种等级的“Java并发编程宝典”谁能撰写?...

    前言 大家都知道并发编程技术就是在同一个处理器上同时的去处理多个任务,充分的利用到处理器的每个核心,最大化的发挥处理器的峰值性能,这样就可以避免我们因为性能而产生的一些问题. 大厂的核心负载肯定是非常 ...

  7. 【极客时间】《Java并发编程实战》学习笔记

    目录: 开篇词 | 你为什么需要学习并发编程? 内容来源:开篇词 | 你为什么需要学习并发编程?-极客时间 例如,Java 里 synchronized.wait()/notify() 相关的知识很琐 ...

  8. 《Java 并发编程实战》--读书笔记

    Java 并发编程实战 注: 极客时间<Java 并发编程实战>–读书笔记 GitHub:https://github.com/ByrsH/Reading-notes/blob/maste ...

  9. Java并发编程实战————Semaphore信号量的使用浅析

    引言 本篇博客讲解<Java并发编程实战>中的同步工具类:信号量 的使用和理解. 从概念.含义入手,突出重点,配以代码实例及讲解,并以生活中的案例做类比加强记忆. 什么是信号量 Java中 ...

  10. java并发编程实战学习(3)--基础构建模块

    转自:java并发编程实战 5.3阻塞队列和生产者-消费者模式 BlockingQueue阻塞队列提供可阻塞的put和take方法,以及支持定时的offer和poll方法.如果队列已经满了,那么put ...

最新文章

  1. js创建对象的几种方法
  2. 精益数据分析 | 你孜孜追求的“增长”可能只是自嗨
  3. python configuration_Python(2.7.6) 标准日志模块 - Logging Configuration
  4. 【最新合集】编译原理习题(含答案)_8-10语法制导翻译_MOOC慕课 哈工大陈鄞
  5. redis 队列_Redis系列5实现简单消息队列
  6. vs环境下C++dll生成和使用(基础篇)
  7. recorder_将Java Flight Recorder与OpenJDK 11一起使用
  8. c#遍历一个文件夹下的所有文件包括子文件夹【原】
  9. ubuntu 是基于debian gnu/linux,在 Ubuntu 或其它 GNU/Linux 系统下安装 Debian
  10. 幅相频率特性曲线的绘制(1)
  11. 飞思卡尔智能车参赛感受,以及开源自己搜集的资料
  12. 企业级自动化运维工具-ansible
  13. glove.840B.300d glove词向量词嵌入文件国内服务器下载
  14. 不可不知的安卓屏幕知识
  15. win 访问linux加密硬盘分区,手把手教你使用BitLocker给win10硬盘分区加密的方法
  16. 没有配置resolv.conf
  17. darknet整体框架
  18. 幽灵行动4 虚拟服务器,《幽灵行动4:未来战士》游戏进不去解决方法
  19. 雪人计划服务器型号,雪人计划 ipv6网络是什么
  20. SpringCloud的简单使用

热门文章

  1. c# legend 显示位置_添加地图图例 Arcengine+C#
  2. 备案之前要暂停域名解析
  3. 全国各省、市名称(包括县级市)
  4. 干货!abb阀门定位器可克服时滞防止泄漏
  5. 测试计划和测试方案的核心内容
  6. asix 发布webservice 的问题
  7. 数据分析 --- 可视化图表
  8. linux 编译链接库:-lz -lrt -lm -lc
  9. 塑化行业SRM供应商管理系统:缩短采购周期时间,改善供应商采购管理
  10. 前端工具库 xlsx 处理表头合并