这里继续学习记录java8的并发知识。关于什么是并发,什么是并行,什么是进程,什么是线程,有什么关系区别等等就不贴出来啦。
并发在Java5中首次被引入并在后续的版本中不断得到增强。Java从JDK1.0开始执行线程。在开始一个新的线程之前,你必须指定由这个线程执行的代码,通常称为task。
线程与执行器
Runnable
我们可以通过实现Runnable
--一个定义了一个无返回值无参数的run()方法的函数接口,来实现task。
/**
* Runnable
*
* @author wenqy
* @date 2020年1月17日 下午3:19:58
*/
private void testRunnable() {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println(“Hello “ + threadName);
};
task.run(); // 非线程方式调用,还在主线程里
Thread thread = new Thread(task);
thread.start();
System.out.println(“Done!”); // runnable是在打印’done’前执行还是在之后执行,顺序是不确定的
}
我们可以将线程休眠确定的时间。通过这种方法来模拟长时间运行的任务。
/**
* 设置线程休眠时间,模拟长任务
*
* @author wenqy
* @date 2020年1月17日 下午3:25:01
*/
private void testRunnableWithSleep() {
Runnable runnable = () -> {
try {
String name = Thread.currentThread().getName();
System.out.println(“Foo “ + name);
TimeUnit.SECONDS.sleep(1); // 休眠1s
System.out.println(“Bar “ + name);
}
catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(runnable);
thread.start();
}
Executor
并发API引入了ExecutorService
作为一个在程序中直接使用Thread的高层次的替换方案。Executors
支持运行异步任务,通常管理一个线程池,这样一来我们就不需要手动去创建新的线程。在不断地处理任务的过程中,线程池内部线程将会得到复用,Java进程从没有停止!Executors
必须显式的停止-否则它们将持续监听新的任务。
ExecutorService
提供了两个方法来达到这个目的--shutdwon()
会等待正在执行的任务执行完而shutdownNow()
会终止所有正在执行的任务并立即关闭executor。
ExecutorService executor = Executors.newSingleThreadExecutor(); // 单线程线程池
executor.submit(() -> {
String threadName = Thread.currentThread().getName();
System.out.println(“Hello “ + threadName);
try {
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
System.err.println(“my is interrupted”);
} // 休眠1s
});
// => Hello pool-1-thread-1
// Executors必须显式的停止-否则它们将持续监听新的任务
try {
System.out.println(“attempt to shutdown executor”);
executor.shutdown(); // 等待正在执行的任务执行完
executor.awaitTermination(5, TimeUnit.SECONDS); // 等待指定时间优雅关闭executor。在等待最长5s的时间后,executor最终会通过中断所有的正在执行的任务关闭
System.out.println(“wait for 5s to shutdown”);
} catch (InterruptedException e) {
System.err.println(“tasks interrupted”);
} finally {
if (!executor.isTerminated()) {
System.err.println(“cancel non-finished tasks”);
}
executor.shutdownNow(); // 终止所有正在执行的任务并立即关闭executor
System.out.println(“shutdown finished”);
}
Callable
Callables也是类似于runnables的函数接口,不同之处在于,Callable返回一个值。一样提交给 executor services。在调用get()
方法时,当前线程会阻塞等待,直到callable在返回实际的结果 123 之前执行完成。
Callable<Integer> task = () -> {
try {
TimeUnit.SECONDS.sleep(5); // 休眠5s后返回整数
return 123;
}
catch (InterruptedException e) {
throw new IllegalStateException(“task interrupted”, e);
}
};
ExecutorService executor = Executors.newFixedThreadPool(1); // 固定线程池
Future<Integer> future = executor.submit(task);
System.out.println(“future done? “ + future.isDone());
// executor.shutdownNow(); // 如果关闭executor,所有的未中止的future都会抛出异常。
Integer result = future.get(); // 在调用get()方法时,当前线程会阻塞等待,直到callable在返回实际的结果123之前执行完成
System.out.println(“future done? “ + future.isDone());
System.out.println(“result: “ + result);
executor.shutdownNow(); // 需要显式关闭
System.out.println(“result: “ + future.get());
}
任何future.get()
调用都会阻塞,然后等待直到callable中止。在最糟糕的情况下,一个callable持续运行--因此使你的程序将没有响应。我们可以简单的传入一个时长来避免这种情况。
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(() -> {
try {
TimeUnit.SECONDS.sleep(2);
return 123;
}
catch (InterruptedException e) {
throw new IllegalStateException(“task interrupted”, e);
}
});
// 任何future.get()调用都会阻塞,然后等待直到callable中止,传入超时时长终止
future.get(1, TimeUnit.SECONDS); // 抛出 java.util.concurrent.TimeoutException
invokeAll
Executors支持通过invokeAll()
一次批量提交多个callable。这个方法结果一个callable的集合,然后返回一个future的列表。
ExecutorService executor = Executors.newWorkStealingPool(); // ForkJoinPool 一个并行因子数来创建,默认值为主机CPU的可用核心数
List<Callable<String>> callables = Arrays.asList(
() -> “task1”,
() -> “task2”,
() -> “task3”);
executor.invokeAll(callables)
.stream()
.map(future -> { // 返回的所有future,并每一个future映射到它的返回值
try {
return future.get();
}
catch (Exception e) {
throw new IllegalStateException(e);
}
})
.forEach(System.out::println);
invokeAny
批量提交callable的另一种方式就是invokeAny()
,它的工作方式与invokeAll()
稍有不同。在等待future对象的过程中,这个方法将会阻塞直到第一个callable中止然后返回这一个callable的结果。
private static Callable<String> callable(String result, long sleepSeconds) {
return () -> {
TimeUnit.SECONDS.sleep(sleepSeconds);
return result;
};
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newWorkStealingPool();
List<Callable<String>> callables = Arrays.asList(
callable(“task1”, 2),
callable(“task2”, 1),
callable(“task3”, 3));
String result = executor.invokeAny(callables);
System.out.println(result); // task2
}
这个例子又使用了另一种方式来创建executor--调用newWorkStealingPool()
。这个工厂方法是Java8引入的,返回一个ForkJoinPool
类型的 executor,它的工作方法与其他常见的execuotr稍有不同。与使用一个固定大小的线程池不同,ForkJoinPools
使用一个并行因子数来创建,默认值为主机CPU的可用核心数。
ScheduledExecutor
为了持续的多次执行常见的任务,我们可以利用调度线程池。ScheduledExecutorService
支持任务调度,持续执行或者延迟一段时间后执行。调度一个任务将会产生一个专门的future类型--ScheduleFuture
,它除了提供了Future的所有方法之外,他还提供了getDelay()
方法来获得剩余的延迟。在延迟消逝后,任务将会并发执行。
为了调度任务持续的执行,executors 提供了两个方法scheduleAtFixedRate()
和scheduleWithFixedDelay()
。第一个方法用来以固定频率来执行一个任务,另一个方法等待时间是在一次任务的结束和下一个任务的开始之间
/**
* 获取剩余延迟
* @throws InterruptedException
* @author wenqy
* @date 2020年1月17日 下午4:56:58
*/
private static void scheduleDelay() throws InterruptedException {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> System.out.println(“Scheduling: “ + System.nanoTime());
ScheduledFuture<?> future = executor.schedule(task, 3, TimeUnit.SECONDS); // 3s后执行
TimeUnit.MILLISECONDS.sleep(1337);
long remainingDelay = future.getDelay(TimeUnit.MILLISECONDS);
System.out.printf(“Remaining Delay: %sms\n”, remainingDelay); // 剩余的延迟
executor.shutdown();
}
/**
* 以固定频率来执行一个任务
*
* @throws InterruptedException
* @author wenqy
* @date 2020年1月17日 下午4:57:45
*/
private static void scheduleAtFixedRate() throws InterruptedException {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> {
System.out.println(“at fixed rate Scheduling: “ + System.nanoTime());
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
};
int initialDelay = 0; // 初始化延迟,用来指定这个任务首次被执行等待的时长
int period = 1;
executor.scheduleAtFixedRate(task, initialDelay, period, TimeUnit.SECONDS); // 不考虑任务的实际用时
}
/**
* 以固定延迟来执行一个任务
* 等待时间 period 是在一次任务的结束和下一个任务的开始之间
* @throws InterruptedException
* @author wenqy
* @date 2020年1月17日 下午5:03:28
*/
private static void scheduleWithFixedDelay() throws InterruptedException {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(“WithFixedDelay Scheduling: “ + System.nanoTime());
}
catch (InterruptedException e) {
System.err.println(“task interrupted”);
}
};
executor.scheduleWithFixedDelay(task, 0, 1, TimeUnit.SECONDS);
}
同步与锁
我们学到了如何通过执行器服务同时执行代码。当我们编写这种多线程代码时,我们需要特别注意共享可变变量的并发访问。
int count = 0;
void increment() {
count = count + 1;
}
我们在不同的线程上共享可变变量,并且变量访问没有同步机制,这会产生竞争条件。上面例子被多个线程同时访问,就会出现未知的错误。
我们可以用synchronized
关键字支持线程同步
synchronized void incrementSync() {
count = count + 1;
}
synchronized
关键字也可用于语句块
void incrementSync2() {
synchronized (this) {
count = count + 1;
}
}
java在内部使用所谓的**"监视器"**(monitor),也称为监视器锁(monitor lock)或内在锁( intrinsic lock)来管理同步。监视器绑定在对象上,例如,当使用同步方法时,每个方法都共享相应对象的相同监视器。
所有隐式的监视器都实现了重入(reentrant
)特性。重入的意思是锁绑定在当前线程上。线程可以安全地多次获取相同的锁,而不会产生死锁(例如,同步方法调用相同对象的另一个同步方法)
并发API支持多种显式的锁,它们由Lock
接口规定,用于代替synchronized
的隐式锁。锁对细粒度的控制支持多种方法,因此它们比隐式的监视器具有更大的开销。
ReentrantLock
ReentrantLock
类是互斥锁,与通过synchronized
访问的隐式监视器具有相同行为,但是具有扩展功能。就像它的名称一样,这个锁实现了重入特性,就像隐式监视器一样。
锁可以通过lock()
来获取,通过unlock()
来释放。把你的代码包装在try-finally代码块中来确保异常情况下的解锁非常重要。
/**
* 可重入锁
*
* @author wenqy
* @date 2020年1月18日 下午3:41:50
*/
private void safeIncreByLock() {
count = 0;
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
lock.lock();
try {
ConcurrentUtils.sleep(1);
} finally {
lock.unlock();
}
});
executor.submit(() -> {
System.out.println(“Locked: “ + lock.isLocked());
System.out.println(“Held by me: “ + lock.isHeldByCurrentThread());
boolean locked = lock.tryLock(); // 尝试拿锁而不阻塞当前线程
// 在访问任何共享可变变量之前,必须使用布尔值结果来检查锁是否已经被获取
System.out.println(“Lock acquired: “ + locked);
});
ConcurrentUtils.stop(executor);
}
ReadWriteLock
ReadWriteLock
接口规定了锁的另一种类型,包含用于读写访问的一对锁。读写锁的理念是,只要没有任何线程写入变量,并发读取可变变量通常是安全的。所以读锁可以同时被多个线程持有,只要没有线程持有写锁。这样可以提升性能和吞吐量,因为读取比写入更加频繁。
/**
* 读写锁
*
* @author wenqy
* @date 2020年1月18日 下午3:41:21
*/
private void readWriteLock() {
ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();
executor.submit(() -> {
lock.writeLock().lock();
try {
ConcurrentUtils.sleep(1);
map.put(“foo”, “bar”);
} finally {
lock.writeLock().unlock();
}
});
Runnable readTask = () -> {
lock.readLock().lock();
try {
System.out.println(map.get(“foo”));
ConcurrentUtils.sleep(1);
} finally {
lock.readLock().unlock();
}
};
executor.submit(readTask);
executor.submit(readTask);
ConcurrentUtils.stop(executor);
// 两个读任务需要等待写任务完成。在释放了写锁之后,两个读任务会同时执行,并同时打印结果。
// 它们不需要相互等待完成,因为读锁可以安全同步获取
}
StampedLock
Java 8 自带了一种新的锁,叫做StampedLock
,它同样支持读写锁,就像上面的例子那样。与ReadWriteLock
不同的是,StampedLock
的锁方法会返回表示为long的标记。你可以使用这些标记来释放锁,或者检查锁是否有效。
/**
* java8 StampedLock
* tampedLock并没有实现重入特性,相同线程也要注意死锁
* @author wenqy
* @date 2020年1月18日 下午3:40:46
*/
private void stampedLock() {
ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
StampedLock lock = new StampedLock();
executor.submit(() -> {
long stamp = lock.writeLock(); // 读锁或写锁会返回一个标记
try {
ConcurrentUtils.sleep(1);
map.put(“foo”, “bar”);
} finally {
lock.unlockWrite(stamp);
}
});
Runnable readTask = () -> {
long stamp = lock.readLock();
try {
System.out.println(map.get(“foo”));
ConcurrentUtils.sleep(1);
} finally {
lock.unlockRead(stamp);
}
};
executor.submit(readTask);
executor.submit(readTask);
ConcurrentUtils.stop(executor);
}
此外,StampedLock支持另一种叫做乐观锁(optimistic locking)的模式。乐观的读锁通过调用tryOptimisticRead()
获取,它总是返回一个标记而不阻塞当前线程,无论锁是否真正可用。如果已经有写锁被拿到,返回的标记等于0。你需要总是通过lock.validate(stamp)
检查标记是否有效。
/**
* 乐观锁
* 乐观锁在刚刚拿到锁之后是有效的。和普通的读锁不同的是,乐观锁不阻止其他线程同时获取写锁。
* 在第一个线程暂停一秒之后,第二个线程拿到写锁而无需等待乐观的读锁被释放。
* 此时,乐观的读锁就不再有效了。甚至当写锁释放时,乐观的读锁还处于无效状态。
* 所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。
*
* @author wenqy
* @date 2020年1月18日 下午3:49:31
*/
private void optimisticLock() {
System.out.println(“—–>optimisticLock—->”);
ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();
executor.submit(() -> {
long stamp = lock.tryOptimisticRead();
try {
System.out.println(“Optimistic Lock Valid: “ + lock.validate(stamp));
ConcurrentUtils.sleep(1);
System.out.println(“Optimistic Lock Valid: “ + lock.validate(stamp));
ConcurrentUtils.sleep(2);
System.out.println(“Optimistic Lock Valid: “ + lock.validate(stamp));
} finally {
lock.unlock(stamp);
}
});
executor.submit(() -> {
long stamp = lock.writeLock();
try {
System.out.println(“Write Lock acquired”);
ConcurrentUtils.sleep(2);
} finally {
lock.unlock(stamp);
System.out.println(“Write done”);
}
});
ConcurrentUtils.stop(executor);
}
/**
* 读锁转换为写锁
*
* @author wenqy
* @date 2020年1月18日 下午4:00:10
*/
private void convertToWriteLock() {
count = 0;
System.out.println(“—–>convertToWriteLock—->”);
ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();
executor.submit(() -> {
long stamp = lock.readLock();
try {
if (count == 0) {
stamp = lock.tryConvertToWriteLock(stamp); // 读锁转换为写锁而不用再次解锁和加锁
if (stamp == 0L) { // 调用不会阻塞,但是可能会返回为零的标记,表示当前没有可用的写锁
System.out.println(“Could not convert to write lock”);
stamp = lock.writeLock(); // 阻塞当前线程,直到有可用的写锁
}
count = 23;
}
System.out.println(count);
} finally {
lock.unlock(stamp);
}
});
ConcurrentUtils.stop(executor);
}
信号量
除了锁之外,并发API也支持计数的信号量。不过锁通常用于变量或资源的互斥访问,信号量可以维护整体的准入许可。
/**
* 信号量
*
* @author wenqy
* @date 2020年1月18日 下午4:13:11
*/
private void doSemaphore() {
ExecutorService executor = Executors.newFixedThreadPool(10);
Semaphore semaphore = new Semaphore(5); // 并发访问总数
Runnable longRunningTask = () -> {
boolean permit = false;
try {
permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);
if (permit) {
System.out.println(“Semaphore acquired”);
ConcurrentUtils.sleep(5);
} else { // 等待超时之后,会向控制台打印不能获取信号量的结果
System.out.println(“Could not acquire semaphore”);
}
} catch (InterruptedException e) {
throw new IllegalStateException(e);
} finally {
if (permit) {
semaphore.release();
}
}
};
IntStream.range(0, 10)
.forEach(i -> executor.submit(longRunningTask));
ConcurrentUtils.stop(executor);
}
原子变量
java.concurrent.atomic
包包含了许多实用的类,用于执行原子操作。如果你能够在多线程中同时且安全地执行某个操作,而不需要synchronized
关键字或锁,那么这个操作就是原子的。
本质上,原子操作严重依赖于比较与交换(CAS),它是由多数现代CPU直接支持的原子指令。这些指令通常比同步块要快。所以在只需要并发修改单个可变变量的情况下,我建议你优先使用原子类,而不是锁。可以看下java包提供的一些原子变量例子AtomicInteger、LongAdder 、LongAccumulator、concurrentHashMap等等。
/**
* AtomicInteger
* incrementAndGet
* @author wenqy
* @date 2020年1月18日 下午4:27:10
*/
private void atomicIntegerIncre() {
AtomicInteger atomicInt = new AtomicInteger(0);
ExecutorService executor = Executors.newFixedThreadPool(2);
IntStream.range(0, 1000)
.forEach(i -> executor.submit(atomicInt::incrementAndGet)); // ++
ConcurrentUtils.stop(executor);
System.out.println(atomicInt.get()); // => 1000
}
/**
* AtomicInteger
* updateAndGet
* @author wenqy
* @date 2020年1月18日 下午4:29:26
*/
private void atomicIntegerUpdateAndGet() {
AtomicInteger atomicInt = new AtomicInteger(0);
ExecutorService executor = Executors.newFixedThreadPool(2);
IntStream.range(0, 1000)
.forEach(i -> {
Runnable task = () ->
atomicInt.updateAndGet(n -> n + 2); // 结果累加2
executor.submit(task);
});
ConcurrentUtils.stop(executor);
System.out.println(atomicInt.get()); // => 2000
}
/**
* LongAdder
* AtomicLong的替代,用于向某个数值连续添加值
* 内部维护一系列变量来减少线程之间的争用,而不是求和计算单一结果
* 当多线程的更新比读取更频繁时,这个类通常比原子数值类性能更好。
* 这种情况在抓取统计数据时经常出现,例如,你希望统计Web服务器上请求的数量。
* LongAdder缺点是较高的内存开销,因为它在内存中储存了一系列变量。
* @author wenqy
* @date 2020年1月18日 下午4:33:29
*/
private void longAdder() {
LongAdder adder = new LongAdder();
ExecutorService executor = Executors.newFixedThreadPool(2);
IntStream.range(0, 1000)
.forEach(i -> executor.submit(adder::increment));
ConcurrentUtils.stop(executor);
System.out.println(adder.sumThenReset()); // => 1000
}
/**
* LongAccumulator
* LongAccumulator是LongAdder的更通用的版本
* 内部维护一系列变量来减少线程之间的争用
* @author wenqy
* @date 2020年1月18日 下午4:35:11
*/
private void longAccumulator() {
LongBinaryOperator op = (x, y) -> 2 * x + y;
LongAccumulator accumulator = new LongAccumulator(op, 1L);
ExecutorService executor = Executors.newFixedThreadPool(2);
// i=0 2 * 1 + 0 = 2;
// i=2 2 * 2 + 2 = 6;
// i=3 2 * 6 + 3 = 15;
// i=4 2 * 15 + 4 = 34;
IntStream.range(0, 10)
.forEach(i -> executor.submit(() -> {
accumulator.accumulate(i);
System.out.println(“i:” + i + ” result:” + accumulator.get());
})
);
// 初始值为1。每次调用accumulate(i)的时候,当前结果和值i都会作为参数传入lambda表达式。
ConcurrentUtils.stop(executor);
System.out.println(accumulator.getThenReset()); // => 2539
}
/**
* concurrentMap
*
* @author wenqy
* @date 2020年1月18日 下午4:38:09
*/
private void concurrentMap() {
System.out.println(“—–>concurrentMap—–>”);
ConcurrentMap<String, String> map = new ConcurrentHashMap<>();
map.put(“foo”, “bar”);
map.put(“han”, “solo”);
map.put(“r2”, “d2”);
map.put(“c3”, “p0”);
map.forEach((key, value) -> System.out.printf(“%s = %s\n”, key, value));
String value = map.putIfAbsent(“c3”, “p1”);
System.out.println(value); // p0 提供的键不存在时,将新的值添加到映射
System.out.println(map.getOrDefault(“hi”, “there”)); // there 传入的键不存在时,会返回默认值
map.replaceAll((key, val) -> “r2”.equals(key) ? “d3” : val);
System.out.println(map.get(“r2”)); // d3
map.compute(“foo”, (key, val) -> val + val);
System.out.println(map.get(“foo”)); // barbar 转换单个元素,而不是替换映射中的所有值
map.merge(“foo”, “boo”, (oldVal, newVal) -> newVal + ” was “ + oldVal);
System.out.println(map.get(“foo”)); // boo was foo
}
/**
* concurrentHashMap
*
* @author wenqy
* @date 2020年1月18日 下午4:38:42
*/
private void concurrentHashMap() {
System.out.println(“—–>concurrentHashMap—–>”);
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put(“foo”, “bar”);
map.put(“han”, “solo”);
map.put(“r2”, “d2”);
map.put(“c3”, “p0”);
map.forEach(1, (key, value) ->
System.out.printf(“key: %s; value: %s; thread: %s\n”,
key, value, Thread.currentThread().getName())); // 可以并行迭代映射中的键值对
String result = map.search(1, (key, value) -> {
System.out.println(Thread.currentThread().getName());
if (“foo”.equals(key)) { // 当前的键值对返回一个非空的搜索结果
return value; // 只要返回了非空的结果,就不会往下搜索了
}
return null;
}); // ConcurrentHashMap是无序的。搜索函数应该不依赖于映射实际的处理顺序
System.out.println(“Result: “ + result);
String searchResult = map.searchValues(1, value -> {
System.out.println(Thread.currentThread().getName());
if (value.length() > 3) {
return value;
}
return null;
}); // 搜索映射中的值
System.out.println(“Result: “ + searchResult);
String reduceResult = map.reduce(1,
(key, value) -> {
System.out.println(“Transform: “ + Thread.currentThread().getName());
return key + “=” + value;
},
(s1, s2) -> {
System.out.println(“Reduce: “ + Thread.currentThread().getName());
return s1 + “, “ + s2;
});
// 第一个函数将每个键值对转换为任意类型的单一值。
// 第二个函数将所有这些转换后的值组合为单一结果,并忽略所有可能的null值
System.out.println(“Result: “ + reduceResult);
}
我们学了java8的一些新特性和并发编程例子,暂且告一段落了,demo已上传至github:https://github.com/wenqy/java-study
参考
https://github.com/winterbe/java8-tutorial java8教程
https://wizardforcel.gitbooks.io/modern-java/content/ 中文译站
https://github.com/wenqy/java-study 学习例子
本文由 wenqy 创作,采用 知识共享署名4.0
国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: Nov 7,2020