因为书中涵盖的知识点比较全,所以就以书中的目录来学习和记录。当然,学习书中知识的时候自己的思考和实践是最重要的。说到线程,脑子里大概知道是个什么东西,但很多东西都还是懵懵懂懂,这是最可怕的。所以想着细致的来学习一下,就从这本实战开始学习。
疑问点:什么时候会用到多线程?什么情况下使用多线程来解决问题比较合适?

线程的创建和运行

就像学习任何知识一样,要学线程,先得学一下线程是怎么声明(创建)和运行起来的。
一般来说,java创建线程有两种常用的方式(线程池后面再谈):

  1、继承Thread类,并且覆盖run()方法。

  2、实现Runnable接口类,使用带参数的Thread构造器来创建Thread对象,参数就是Runnable接口的一个对象。

那么创建完了怎么运行呢?调用run()方法?调用run()方法只是简单的 类对象调用自己的成员方法,那么怎么会开启线程呢?而且每一个线程还有自己的信息(线程名字,线程ID,优先级等)。那么应该是怎么运行呢?答案是调用start()方法。

来个实例更加直观:
  • 继承Thread类
 //创建
public class Thread2 extends Thread{
@Override
public void run() {
System.out.println("继承Thread类创建线程");
}
}
//运行
private static void createThread2() {
Thread2 t2 = new Thread2();
Thread thread = new Thread(t2);
thread.start();
}
  • 实现Runnable接口
 //创建
public class Thread1 implements Runnable {
@Override
public void run() {
System.out.println("实现Runnable接口创建线程");
}
}
//运行
private static void createThread1() {
Runnable t1 = new Thread1();
Thread thread = new Thread(t1);
thread.start();
}

线程信息的获取和设置

Thread类有一些保存信息的属性,这些属性可以用来标识线程,显示线程状态或者控制线程的优先级。其实这些信息在Thread类中都可以找到,也就是Thread类的一些成员变量。
  • ID:保存线程的唯一标识符
  • Name:线程名称
  • Priority:线程对象的优先级,线程优先级从1-10,1是最低优先级,10是最高优先级。

  • Status:线程状态。在Java中,线程的状态有6种:new(创建)、runnbale(运行)、blocked(阻塞)、waiting(等待)、time waiting和terminated(终止)。状态转换图:

 这个图和上面的六种状态有点差异,六种状态是在Thread类中的枚举类State中的六种枚举。
 public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
在记忆的时候可以像上图中所示的那样,线程的状态转换图总是会忘掉,可以类比来记忆。线程的物种状态 创建、就绪、运行、阻塞和终止状态 类比开车的过程。
创建--->把车从车库拿出来
就绪--->坐在驾驶位置上,发动,拉手刹
运行--->车启动,开始走
阻塞--->路口红灯需要停车等待
就绪--->红灯停止,拉手刹开车
终止--->到达目的地,停车
例子举的不是很恰当,其实状态转换主要还是就绪、运行和阻塞三个状态的切换。运行状态运行一段时间后因为某些原因转阻塞状态,阻塞状态解除后 不会立马到运行状态而是到就绪状态,只有就绪状态才有可能获取CPU调度然后重新 变为运行状态。

线程的中断

就是结束正在运行的线程。中断方式有以下几种:
  • 调用interrupt()方法,当前执行的线程就会被中断。
 task.interrupt();

判断一个线程是否被中断了,有两种方式:

  1. interrupted(),检查当前执行的线程是否被中断
  2. isInterrupted():当interrupt()方法被调用时,Thread类中表名线程是否被中断的属性会被设置为true。isInterrupted()方法返回这个属性的值。

推荐使用isInterrupted(),因为interrupted()方法是一个静态方法。

 public static boolean interrupted() {
return currentThread().isInterrupted(true);
}

线程中断的控制

如果线程实现了复杂的算法那并且分布在几个方法中,或者线程中有递归调用的方法,如何去中断线程?因为直接 调用interrupt()方法不能立竿见影。java还提供了InterruptException异常。当检查到线程中断的时候,就抛出异常,然后在run()方法中捕获并处理这个异常。
run()方法中捕获异常,打印线程信息
 @Override
public void run() {
File file = new File(initPath);
if (file.isDirectory()) {
try {
dirctoryProcess(file);
} catch (InterruptedException e) {
System.out.printf("%s:the search has been interrupted", Thread.currentThread().getName());
}
}
}
在dirctoryProcess()方法中抛出异常。
 private void dirctoryProcess(File file) throws InterruptedException {
File[] list = file.listFiles();
if (list != null) {
for (int i = 0; i < list.length; i++) {
if (list[i].isDirectory()) {
dirctoryProcess(list[i]);
} else {
fileProcess(list[i]);
}
}
}
if (Thread.interrupted()) {
throw new InterruptedException();
}
}

线程的休眠和恢复

java里面提供了sleep()方法来休眠线程。需要掌握的知识点:
  • 休眠指定时间后线程自动恢复
  • 线程休眠的方式有两种:
    • Thread.sleep(1000);//休眠一秒,其中这里的单位为ms
    • TimeUnit.SECONDS.sleep(1);//休眠一秒,这里的单位为s

等待线程的终止(join())

当一个线程对象的join()方法被调用时,调用它的线程将被挂起,直到这个线程对象完成它的任务。意思就是:如果在线程B中线程A调用了join()方法,那么只有A线程执行完毕后,才会接着执行线程B下面的代码。
DataSourceLoader睡眠3秒
 public class DataSourceLoader implements Runnable {
@Override
public void run() {
System.out.printf("Begining data sources loading: %s\n",new Date());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("Data Sources loading has finised:%s\n",new Date());
}
}
NetWorkConnectionLoader睡眠6秒
 public class NetWorkConnectionLoader implements Runnable {
@Override
public void run() {
System.out.printf("Begining data sources loading: %s\n",new Date());
try {
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("Data Sources loading has finised:%s\n",new Date());
}
}
执行Main
 public class Main {
public static void main(String[] args) {
DataSourceLoader dsLoader = new DataSourceLoader();
Thread t1 = new Thread(dsLoader,"DataSourceThread");
NetWorkConnectionLoader ncLoader = new NetWorkConnectionLoader();
Thread t2 = new Thread(ncLoader,"NetWorkConnectionThread");
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("Main: Configuration has been loaded:%s\n",new Date());
}
}
输出结果如下:
 Begining data sources loading: Tue Apr 25 14:46:39 CST 2017
Begining data sources loading: Tue Apr 25 14:46:39 CST 2017
Data Sources loading has finised:Tue Apr 25 14:46:42 CST 2017
NetWorkConnectionLoader loading has finised:Tue Apr 25 14:46:45 CST 2017
Main: Configuration has been loaded:Tue Apr 25 14:46:45 CST 2017
从结果可以看出:对于main函数线程,如果t1,t2没有调用join函数(),则“
Main: Configuration has been loaded:Tue Apr 25 14:46:45 CST 2017
”这句话应该是先打印的,由于t1,t2调用join函数,结果是main要等待t1,t2执行完成后再执行main线程,即使t1,t2睡眠了一段时间。
java还提供了另外两种形式的join()方法:
  • join(long milliseconds)
  • join(long milliseconds,long nanos)

当一个线程调用其他线程的join()方法时,如果使用的是第一种join()方式,那么它不必等到被调用线程运行终止,如果参数指定的毫秒时钟已经到达,它将继续运行。例如:thread1中有这样的代码thread2.join(1000),thread1将挂起,知道满足下面两个条件之一:

  • thread2运行已经完成
  • 时钟已经过去了1000毫秒

当两个条件中的任何一个成立是,join()方法将返回。

第二种join()方法跟第一种相似,只是需要接受毫秒和纳秒两个参数。
其实join()方法在底层调用的就是join(long milliseconds)方法,只不过传递的值是0,即:
 public final void join() throws InterruptedException {
join(0);
}
join()底层实现有一个wait()方法,这样看就比较好记忆了
 public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}

守护线程的创建和运行

守护线程(Daemon):这种线程的优先级很低,通常来说,当一个应用程序中没有其他线程运行的时候,守护线程才运行(这个是守护线程的特性)。当守护线程是程序中唯一运行的线程时,守护线程执行结束后,JVM也就结束了。一个典型的守护线程是Java的垃圾回收器。
举的例子是一个队列中的数据插入和取出:
其中WriteTask负责向队列中插入数据,循环一百次,插入的内容为 时间和 字符串类型的 事件
 public class WriterTask implements Runnable {
Deque<Event> deque; public WriterTask (Deque<Event> deque){
this.deque=deque;
} @Override
public void run() { // Writes 100 events
for (int i=1; i<100; i++) {
// Creates and initializes the Event objects
Event event=new Event();
event.setDate(new Date());
event.setEvent(String.format("The thread %s has generated an event",Thread.currentThread().getId())); // Add to the data structure
deque.addFirst(event);
try {
// Sleeps during one second
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
 
记录队列中信息的类 Event
 public class Event {

     private Date date;
private String event; public Date getDate() {
return date;
} public void setDate(Date date) {
this.date = date;
} public String getEvent() {
return event;
} public void setEvent(String event) {
this.event = event;
}
}
取出数据的线程是一个守护线程 ClearTask
 public class CleanerTask extends Thread {
private Deque<Event> deque; public CleanerTask(Deque<Event> deque) {
this.deque = deque;
// Establish that this is a Daemon Thread
setDaemon(true);
} @Override
public void run() {
while (true) {
Date date = new Date();
clean(date);
}
} private void clean(Date date) {
long difference;
boolean delete; if (deque.size()==0) {
return;
} delete=false;
do {
Event e = deque.getLast();
difference = date.getTime() - e.getDate().getTime();
if (difference > 10000) {
System.out.printf("Cleaner: %s\n",e.getEvent());
deque.removeLast();
delete=true;
}
} while (difference > 10000);
if (delete){
System.out.printf("Cleaner: Size of the queue: %d\n",deque.size());
}
}
}
最后是执行的Main:
 public static void main(String[] args) {

         Deque<Event> deque=new ArrayDeque<Event>();

         WriterTask writer=new WriterTask(deque);
for (int i=0; i<3; i++){
Thread thread=new Thread(writer);
thread.start();
} CleanerTask cleaner=new CleanerTask(deque);
cleaner.start();
}
三个写线程,一个取线程,值得注意的是,只有当三个写线程都休眠的时候,取线程才开始工作。

线程中不可控异常的处理

java中异常Exception下面分两类,
  • 受检异常(非运行时异常)(Checked Exception)
  • 非受检异常(运行时异常)(UnChecked Exception)

由于线程的run()方法不能接受抛出异常,对于受检异常来说,可以在编写程序时捕获,对于非受检异常来说,因为不知道会不会抛出异常,这样就比较麻烦。好在Java提供了一种在线程对象里捕获和处理运行时异常的一种机制。具体如下:

实现用来处理运行时异常的类,这个类实现UnCaughtExceptionHandler接口并且实现这个接口的uncaughtException()方法
 public class ExceptionHandler implements UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.printf("An exception has been captured\n");
System.out.printf("Thread: %s\n",t.getId());
System.out.printf("Exception: %s: %s\n",e.getClass().getName(),e.getMessage());
System.out.printf("Stack Trace: \n");
e.printStackTrace(System.out);
System.out.printf("Thread status: %s\n",t.getState());
}
}
在run()方法里面制造运行时异常:
 @Override
public void run() {
// The next instruction always throws and exception
int numero=Integer.parseInt("TTT");
}
main函数如下:
 public static void main(String[] args) {
Task task=new Task();
Thread thread=new Thread(task);
thread.setUncaughtExceptionHandler(new ExceptionHandler());
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
} System.out.printf("Thread has finished\n");
}
这样就捕获了运行时异常,运行结果如下:
 An exception has been captured
Thread: 10
Exception: java.lang.NumberFormatException: For input string: "TTT"
Stack Trace:
java.lang.NumberFormatException: For input string: "TTT"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at com.packtpub.java7.concurrency.chapter1.recipe8.task.Task.run(Task.java:16)
at java.lang.Thread.run(Thread.java:745)
Thread status: RUNNABLE
Thread has finished
如果不捕获异常,那么输出结果如下:
 Exception in thread "Thread-0" java.lang.NumberFormatException: For input string: "TTT"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:580)
at java.lang.Integer.parseInt(Integer.java:615)
at com.packtpub.java7.concurrency.chapter1.recipe8.task.Task.run(Task.java:16)
at java.lang.Thread.run(Thread.java:745)
Thread has finished

线程局部变量的使用(ThreadLocal)

多线程中,共享数据是不安全的。为了保证共享数据的安全性有两种思路:
  1. 设置临界区,保证临界区里的数据一次只能有一个线程访问
  2. 为每个线程维护一个该共享数据的局部变量,这样,每个线程各自使用自己的局部变量。ThreadLocal就是这种思路的实现。
使用ThreadLocal的大致思路是:把共享数据包装在ThreadLocal<T>中。包装完成后ThreadLocal提供了取值和设值的方法。提供的方法有:
  • get():返回此线程局部变量的当前线程副本中的值。
  • set():将次线程局部变量的当前线程副本中的值设置为指定值。
  • remove():移除此线程局部变量当前线程的值。
  • initialValue():返回次线程局部变量的当前线程的“初始值”。线程第一次使用get()方法访问变量时将调用此方法,但如果线程之前调用了set(T)方法,则不会对该线程再调用initialValue()方法。通常,此方法对每个线程最多调用一次,但如果在调用get()后又调用了remove(),则可能再次调用此方法。

使用方式如下:

 public class SafeTask implements Runnable {

     private static ThreadLocal<Date> startDate= new ThreadLocal<Date>() {
protected Date initialValue(){
return new Date();
}
}; @Override
public void run() {
// Writes the start date
System.out.printf("Starting Thread: %s : %s\n",Thread.currentThread().getId(),startDate.get());
try {
TimeUnit.SECONDS.sleep((int)Math.rint(Math.random()*10));
} catch (InterruptedException e) {
e.printStackTrace();
}
// Writes the start date
System.out.printf("Thread Finished: %s : %s\n",Thread.currentThread().getId(),startDate.get());
}
}

线程的分组

Java提供了ThreadGroup类表示一组线程。线程组可以包含线程对象,也可以包含其他的线程组对象,它是一个树形结构。
线程组允许把一个组的线程当做一个单元,对组内线程对象进行访问并操作他们。对一些执行相同任务的线程,只要一个单一的调用,所有这些线程的运行都会被中断。
例子是这样的:创建十个线程并让他们休眠一个随机时间(这段时间比如执行了一个查询),当其中一个线程查找成功的时候,我们将中断其他的9个线程。
疑问点:线程组和线程之间是如何绑定的?
 ThreadGroup threadGroup = new ThreadGroup("Searcher");
SearchTask searchTask=new SearchTask(result);
for (int i=0; i<5; i++) {
Thread thread=new Thread(threadGroup, searchTask);
thread.start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
如上所示,这样就绑定了线程searchTask和线程组threadGroup。线程组提供了一些方法,
activeCount():线程组中活动线程的估计数
interrupt():中断此线程中的所有线程
...其他方法以后用到的时候查看API
等待线程组中有线程满足结束条件
 private static void waitFinish(ThreadGroup threadGroup) {
while (threadGroup.activeCount()>9) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
中断线程组中的所有线程
 threadGroup.interrupt();

线程组中不可控异常的处理

在线程中处理不可控异常,上面的做法是编写一个类继承UncaughtExceptionHandler接口,然后使用thread.setUncaughtExceptionHandler(UncaughtExceptionHandler eh)来实现。因为Thread没有继承UncaughtExceptionHandler接口,而是提供了一个方法:
 public void setUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
checkAccess();
uncaughtExceptionHandler = eh;
}
对于线程组来说,线程组直接实现了UncaughtExceptionHandler接口,
 class ThreadGroup implements Thread.UncaughtExceptionHandler {
这样的话,我们就可以声明一个类MyThreadGroup来继承ThreadGroup类,并实现UncaughtExceptionHandler接口唯一的方法:uncaughtException()方法
 public class MyThreadGroup extends ThreadGroup {
public MyThreadGroup(String name) {
super(name);
}
@Override
public void uncaughtException(Thread t, Throwable e) {
}
}
这样的话以后在使用MyThreadGroup的时候,出现了运行时异常,就可以交给已经实现的uncaughtException()方法处理了。

使用工厂类创建线程

Java提供了ThreadFactory接口,这个接口实现了线程对象工厂。
 public interface ThreadFactory {
Thread newThread(Runnable r);
}
工厂类就是用来创建对象的,那么如何使用线程工厂类呢?
其实从上面的接口看到,ThreadFactory定义了唯一的方法:newThread(Runnable r);方法需要一个Runnable类型的参数,这个参数就是我们定义的线程类。回顾一下不用线程工厂类的时候创建线程对象时怎么用的?
 public class Thread1 implements Runnable{
@override
public void run(){
}
}
Runnable t1 = new Thread1();
Thread t = new Thread(t1);
t.start();
使用了工厂之后呢?这么写,继承ThreadFactory接口实现newThread()方法,这里创建线程
 public class MyThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t=new Thread(r,"thread_name");
return t;
}
}
然后创建线程的时候这么写:
 Task task = new Task();
Thread thread=factory.newThread(task);
这么写有什么好处呢?创建线程集中在了new Thread()方法中。
  1. 更容易修改类,或者改变创建对象的方式
  2. 更容易为有限资源创建对象的数目,可以限制一个类型的对象的个数
  3. 更容易为创建的对象生成统计数据

Java并发之线程管理(线程基础知识)的更多相关文章

  1. Java做acm所需要的基础知识之排序问题

    Java做acm所需要的基础知识. 以前做acm的题都是用C/C++来写代码的,在学习完Java之后突然感觉Java中的方法比C/C++丰富很多,所以就整理一下平时做题需要用到的Java基础知识. 1 ...

  2. Java基础之线程——管理线程同步方法(BankOperation2)

    控制台程序. 当两个或多个线程共享同一资源时,例如文件或内存块,就需要采取措施,确保其中的一个线程不会修改另一个线程正在使用的资源.当其中的一个线程更新文件中的某个记录,同时另一个线程正在检索这个记录 ...

  3. 别指望一文读懂Java并发之从一个线程开始

    Understanding concurrent programming is on the same order of difficulty as understanding object-orie ...

  4. java笔记--用ThreadLocal管理线程,Callable<V>接口实现有返回值的线程

    用ThreadLocal管理线程,Callable<V>接口实现有返回值的线程 ThreadLocal在我的笔记"关于线程同步"的第5种方式里面有介绍,这里就不多说了. ...

  5. java并发之如何解决线程安全问题

    并发(concurrency)一个并不陌生的词,简单来说,就是cpu在同一时刻执行多个任务. 而Java并发则由多线程实现的. 在jvm的世界里,线程就像不相干的平行空间,串行在虚拟机中.(当然这是比 ...

  6. java第九节 网络编程的基础知识

    /** * * 网络编程的基础知识 * 网络协议与TCP/IP * IP地址和Port(端口号) * 本地回路的IP地址:127.0.0.1 * 端口号的范围为0-65535之间,0-1023之间的端 ...

  7. 学 Java 网络爬虫,需要哪些基础知识?

    说起网络爬虫,大家想起的估计都是 Python ,诚然爬虫已经是 Python 的代名词之一,相比 Java 来说就要逊色不少.有不少人都不知道 Java 可以做网络爬虫,其实 Java 也能做网络爬 ...

  8. (转)JAVA AJAX教程第二章-JAVASCRIPT基础知识

    开篇:JAVASCRIPT是AJAX技术中不可或缺的一部分,所以想学好AJAX以及现在流行的AJAX框架,学好JAVASCRIPT是最重要的.这章我给大家整理了一些JAVASCRIPT的基础知识.常用 ...

  9. Java基础之线程——管理线程同步代码块(BankOperation4)

    控制台程序. 除了同步类对象的方法之外,还可以把程序中的语句或代码块制定为synchronized,这种方式更强大,因为可以指定哪个对象从语句或代码块的同步中获益,而不像同步方法那样仅仅是包含代码的对 ...

  10. iOS/OS X线程安全的基础知识

    处理多并发和可重入性问题,是每个库发展过程中面临的比较困难的挑战之一.在Parse平台上,我们尽最大的努力保证你在使用我的SDKs时所做的操作都是线程安全的,保证不会出现性能问题. 在这篇文章中我们将 ...

随机推荐

  1. gitlab服务器搭建教程

    gitlab服务器搭建教程 ----2016年终总结 三 参考https://bbs.gitlab.cc/topic/35/gitlab-ce-8-7-%E6%BA%90%E7%A0%81%E5%AE ...

  2. 客户机中PLSQL DEV访问虚拟机中的ORCLE11g,错误百出!

    客户机中PLSQL DEV访问虚拟机中的ORCLE11g,错误百出! 创建时间: 2017/10/14 18:44 作者: CNSIMO 标签: ORACLE 忙了一下午,只有两个字形容:麻烦!   ...

  3. 胡小兔的OI日志3 完结版

    胡小兔的 OI 日志 3 (2017.9.1 ~ 2017.10.11) 标签: 日记 查看最新 2017-09-02 51nod 1378 夹克老爷的愤怒 | 树形DP 夹克老爷逢三抽一之后,由于采 ...

  4. SQLyog快捷键汇总

    Ctrl+M   创建一个新的连接Ctrl+N   使用当前设置新建连接Ctrl+F4   断开当前连接 对象浏览器 F5   刷新对象浏览器(默认)Ctrl+B   设置焦点于对象浏览器 SQL 窗 ...

  5. win10 uwp MVVM入门

    MVVM 是一个强大的架构,基本从 WPF 开始,wr(我说的就是微软)就提倡使用 MVVM.它可以将界面和后台分离,让开发人员可以不关心界面是怎样,全心投入到后台代码编写中. 然后在编写完后台代码后 ...

  6. 解析 .Net Core 注入 (1) 注册服务

    在学习 Asp.Net Core 的过程中,注入可以说是无处不在,对于 .Net Core 来说,它是独立的一个程序集,没有复杂的依赖项和配置文件,所以对于学习 Asp.Net Core 源码的朋友来 ...

  7. Jfinal配置不得不注意的问题

    问题摘要 使用jfinal mvc开发的时候,输出配置环境一定要注意,因为jfinal项目的依赖与log4j,等全部是在此目录下. 使用eclipse做演示一般有两种情况 1. WebContent\ ...

  8. 如何设置App的启动图

    如何设置App的启动图,也就是Launch Image? Step1 1.点击Image.xcassets 进入图片管理,然后右击,弹出"New Launch Image" 2.如 ...

  9. C#方法中参数ref和out的解析

    一.C#方法中参数类型 有4种参数类型,有时候很难记住它们的不同特征,下图对它们做一个总结,使之更容易比较和对照. 二.C#方法中的参数 1.值参数 使用值参数,通过复制实参的值到形参的方式把数据传递 ...

  10. ASP.NET Core 2.0 in Docker on Windows Container

    安装Docker for Windows https://store.docker.com/editions/community/docker-ce-desktop-windows 要想将一个ASP. ...