java多线程(二四)java多线程基础总结
创始人
2024-05-31 09:02:03
0

一、进程与线程

1.进程

进程是操作系统结构的基础;是一次程序的执行;是一个程序及其数据在处理机上顺序执行时所发生的活动。操作系统中,几乎所有运行中的任务对应一条进程(Process)。一个程序进入内存运行,即变成一个进程。进程是处于运行过程中的程序,并且具有一定独立功能。描述进程的有一句话非常经典的话——进程是系统进行资源分配和调度的一个独立单位。
进程是系统中独立存在的实体,拥有自己独立的资源,拥有自己私有的地址空间。进程的实质,就是程序在多道程序系统中的一次执行过程,它是动态产生,动态消亡的,具有自己的生命周期和各种不同的状态。进程具有并发性,它可以同其他进程一起并发执行,按各自独立的、不可预知的速度向前推进。

(注意,并发性(concurrency)和并行性(parallel)是不同的。并行指的是同一时刻,多个指令在多台处理器上同时运行。并发指的是同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,看起来就好像多个指令同时执行一样。)
进程由程序、数据和进程控制块三部分组成。

2、线程

线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
线程是程序中一个单一的顺序控制流程。在单个程序中同时运行多个线程完成不同的工作,称为多线程。
在Java Web中要注意,线程是JVM级别的,在不停止的情况下,跟JVM共同消亡,就是说如果一个Web服务启动了多个Web应用,某个Web应用启动了某个线程,如果关闭这个Web应用,线程并不会关闭,因为JVM还在运行,所以别忘了设置Web应用关闭时停止线程。

二、线程的生命周期及五种基本状态

在这里插入图片描述
上图中基本上囊括了Java中多线程各重要知识点。掌握了上图中的各知识点,Java中的多线程也就基本上掌握了。

Java线程具有五种基本状态

新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread()。

就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行。

运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。

阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

  • 1.等待阻塞 – 运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;

  • 2.同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;

  • 3.其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

三、创建多线程的方式

Java中线程的创建有如下三种基本形式。

1、继承Thread类,重写该类的run()方法。

package com.demo.test;public class MyThread extends Thread {private String name;public MyThread(String name){this.name = name;}@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(name + "运行  :" + i);}}
}package com.demo.test;public class ThreadTest {public static void main(String[] args) {Thread myThread1 = new MyThread("A");     // 创建一个新的线程  myThread1  此线程进入新建状态Thread myThread2 = new MyThread("B");     // 创建一个新的线程 myThread2 此线程进入新建状态myThread1.start();  // 调用start()方法使得线程进入就绪状态myThread2.start();  // 调用start()方法使得线程进入就绪状态}
}

如上所示,继承Thread类,通过重写run()方法定义了一个新的线程类MyThread,其中run()方法的方法体代表了线程需要完成的任务,称之为线程执行体。当创建此线程类对象时一个新的线程得以创建,并进入到线程新建状态。通过调用线程对象引用的start()方法,使得该线程进入到就绪状态,此时此线程并不一定会马上得以执行,这取决于CPU调度时机。

2、实现java.lang.Runnable接口

具体做法:实现Runnable接口,并重写该接口的run()方法,该run()方法同样是线程执行体,创建Runnable实现类的实例,并以此实例作为Thread类的target来创建Thread对象,该Thread对象才是真正的线程对象。

package com.demo.test;public class MyRunnable implements Runnable{private String name;public MyRunnable(String name){this.name = name;}@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(name + "运行  :" + i);}}
}package com.demo.test;public class ThreadTest {public static void main(String[] args) {Runnable myRunnable = new MyRunnable("A"); // 创建一个Runnable实现类的对象Thread thread1 = new Thread(myRunnable); // 将myRunnable作为Thread target创建新的线程Runnable myRunnable1 = new MyRunnable("B");Thread thread2 = new Thread(myRunnable1);thread1.start(); // 调用start()方法使得线程进入就绪状态thread2.start();}
}

3、使用Callable和Future接口创建线程。

具体是创建Callable接口的实现类,并实现call()方法。并使用FutureTask类来包装Callable实现类的对象,且以此FutureTask对象作为Thread对象的target来创建线程。

package com.demo.test;import java.util.concurrent.Callable;public class MyCallable implements Callable{// 与run()方法不同的是,call()方法具有返回值@Overridepublic Integer call() throws Exception{System.out.println("子线程在进行计算");Thread.sleep(3000);int sum = 0;for (int i = 0; i < 100; i++) {//System.out.println(Thread.currentThread().getName() + " " + i);sum += i;}return sum;}}package com.demo.test;import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class ThreadTest {public static void main(String[] args) {Callable myCallable = new MyCallable();    // 创建MyCallable对象FutureTask ft = new FutureTask(myCallable); //使用FutureTask来包装MyCallable对象Thread thread = new Thread(ft);  //FutureTask对象作为Thread对象的target创建新的线程thread.start();  //线程进入到就绪状态try {Thread.sleep(1000);} catch (InterruptedException e1) {e1.printStackTrace();}System.out.println("主线程在执行任务");try {int sum = ft.get(); //取得新创建的线程中的call()方法返回的结果System.out.println("task运行结果,sum = " + sum);} catch (InterruptedException e) {e.printStackTrace();} catch (ExecutionException e) {e.printStackTrace();}System.out.println("所有任务执行完毕");}
}

首先,我们发现,在实现Callable接口中,此时不再是run()方法了,而是call()方法,此call()方法作为线程执行体,同时还具有返回值!在创建新的线程时,是通过FutureTask来包装MyCallable对象,同时作为了Thread对象的target。那么看下FutureTask类的定义:


public class FutureTask implements RunnableFutureFutureTask类实现了RunnableFuture接口,我们看一下RunnableFuture接口的实现:public interface RunnableFuture extends Runnable, Future {void run();
}

于是,我们发现FutureTask类实际上是同时实现了Runnable和Future接口,由此才使得其具有Future和Runnable双重特性。通过Runnable特性,可以作为Thread对象的target,而Future特性,使得其可以取得新创建线程中的call()方法的返回值。
执行下此程序,我们发现sum = 4950永远都是最后输出的。那么为什么sum =4950会永远最后输出呢?原因在于通过ft.get()方法获取子线程call()方法的返回值时,当子线程此方法还未执行完毕,ft.get()方法会一直阻塞,直到call()方法执行完毕才能取到返回值。
上述主要讲解了三种常见的线程创建方式,对于线程的启动而言,都是调用线程对象的start()方法,需要特别注意的是:不能对同一线程对象两次调用start()方法。

四、线程调度

1.线程加入——join()

join —— 让一个线程等待另一个线程完成才继续执行。如A线程执行体中调用B线程的join()方法,则A线程被阻塞,直到B线程执行完为止,A才能得以继续执行。

join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个线程运行结束,当前线程再由阻塞转为就绪状态。

join是Thread类的一个方法,启动线程后直接调用,join() 的作用:让“主线程”等待“子线程”结束之后才能继续运行。

为什么要用join() 方法?
在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。

不加join的情况:

package com.demo.test;public class Thread1 extends Thread{private String name;public Thread1(String name) {super(name);this.name=name;}public void run() {System.out.println(Thread.currentThread().getName() + " 线程运行开始!");for (int i = 0; i < 5; i++) {System.out.println("子线程"+name + "运行 : " + i);try {sleep((int) Math.random() * 10);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println(Thread.currentThread().getName() + " 线程运行结束!");}
}package com.demo.test;public class Main {public static void main(String[] args) {System.out.println(Thread.currentThread().getName()+"主线程运行开始!");Thread1 mTh1=new Thread1("A");Thread1 mTh2=new Thread1("B");mTh1.start();mTh2.start();System.out.println(Thread.currentThread().getName()+ "主线程运行结束!");}
}
运行结果:main主线程运行开始!
A 线程运行开始!
B 线程运行开始!
main主线程运行结束!
子线程B运行 : 0
子线程A运行 : 0
子线程B运行 : 1
子线程A运行 : 1
子线程B运行 : 2
子线程A运行 : 2
子线程B运行 : 3
子线程A运行 : 3
子线程B运行 : 4
子线程A运行 : 4
B 线程运行结束!
A 线程运行结束!

加join 的情况:

package com.demo.test;public class Main {public static void main(String[] args) {System.out.println(Thread.currentThread().getName()+"主线程运行开始!");Thread1 mTh1=new Thread1("A");Thread1 mTh2=new Thread1("B");mTh1.start();mTh2.start();try {mTh1.join();} catch (InterruptedException e) {e.printStackTrace();}try {mTh2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName()+ "主线程运行结束!");}
}运行结果:main主线程运行开始!
A 线程运行开始!
B 线程运行开始!
子线程A运行 : 0
子线程B运行 : 0
子线程A运行 : 1
子线程B运行 : 1
子线程A运行 : 2
子线程B运行 : 2
子线程A运行 : 3
子线程B运行 : 3
子线程A运行 : 4
子线程B运行 : 4
A 线程运行结束!
B 线程运行结束!
main主线程运行结束!

五、线程安全与线程同步

1、一个典型的Java线程安全例子

package com.demo.test;public class Account {private String accountNo;private double balance;public Account() {}public Account(String accountNo, double balance) {this.accountNo = accountNo;this.balance = balance;}public String getAccountNo() {return accountNo;}public void setAccountNo(String accountNo) {this.accountNo = accountNo;}public double getBalance() {return balance;}public void setBalance(double balance) {this.balance = balance;}}package com.demo.test;public class DrawMoneyRunnable implements Runnable{private Account account;private double drawAmount;public DrawMoneyRunnable(Account account, double drawAmount) {super();this.account = account;this.drawAmount = drawAmount;}public void run() {if (account.getBalance() >= drawAmount) {  //1System.out.println("取钱成功, 取出钱数为:" + drawAmount);double balance = account.getBalance() - drawAmount;account.setBalance(balance);System.out.println("余额为:" + balance);}}}package com.demo.test;public class ThreadTest {public static void main(String[] args) {Account account = new Account("123456", 1000);DrawMoneyRunnable drawMoneyRunnable = new DrawMoneyRunnable(account, 700);Thread myThread1 = new Thread(drawMoneyRunnable);Thread myThread2 = new Thread(drawMoneyRunnable);myThread1.start();myThread2.start();}}

上面例子很容易理解,有一张银行卡,里面有1000的余额,程序模拟两个人同时在取款机进行取钱操作的场景。多次运行此程序,可能具有多个不同组合的输出结果。其中一种可能的输出为:

取钱成功, 取出钱数为:700.0
余额为:300.0
取钱成功, 取出钱数为:700.0
余额为:-400.0

也就是说,对于一张只有1000余额的银行卡,你们一共可以取出1400,这显然是有问题的。
经过分析,问题在于Java多线程环境下的执行的不确定性。CPU可能随机的在多个处于就绪状态中的线程中进行切换,因此,很有可能出现如下情况:当thread1执行到//1处代码时,判断条件为true,此时CPU切换到thread2,执行//1处代码,发现依然为真,然后执行完thread2,接着切换到thread1,接着执行完毕。此时,就会出现上述结果。
因此,讲到线程安全问题,其实是指多线程环境下对共享资源的访问可能会引起此共享资源的不一致性。因此,为避免线程安全问题,应该避免多线程环境下对此共享资源的并发访问。

2、多线程的同步

为何要使用同步?
java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查),将会导致数据不准确,相互之间产生冲突,因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。

(1)同步方法

 对共享资源进行访问的方法定义中加上synchronized关键字修饰,使得此方法称为同步方法。可以简单理解成对此方法进行了加锁,其锁对象为当前方法所在的对象自身。多线程环境下,当执行此方法时,首先都要获得此同步锁(且同时最多只有一个线程能够获得),只有当线程执行完此同步方法后,才会释放锁对象,其他的线程才有可能获取此同步锁,以此类推...。

在上例中,共享资源为account对象,当使用同步方法时,可以解决线程安全问题。只需在run()方法前加上synchronized关键字即可。

public synchronized void run() {

}

(2)同步代码块

正如上面所分析的那样,解决线程安全问题其实只需限制对共享资源访问的不确定性即可。使用同步方法时,使得整个方法体都成为了同步执行状态,会使得可能出现同步范围过大的情况,于是,针对需要同步的代码可以直接另一种同步方式——同步代码块来解决。

同步代码块的格式为:

其中,obj为锁对象,因此,选择哪一个对象作为锁是至关重要的。一般情况下,都是选择此共享资源对象作为锁对象。

如上例中,最好选用account对象作为锁对象。(当然,选用this也是可以的,那是因为创建线程使用了runnable方式,如果是直接继承Thread方式创建的线程,使用this对象作为同步锁其实没有起到任何作用,因为是不同的对象了。因此,选择同步锁时需要格外小心…)

关于synchronized关键字的说明:

关于synchronized关键字的说明:

① 原理

在java中,每一个对象有且仅有一个同步锁。这也意味着,同步锁是依赖于对象而存在。当前线程调用某对象的synchronized方法时,就获取了该对象的同步锁。例如,synchronized(obj),当前线程就获取了“obj这个对象”的同步锁。不同线程对同步锁的访问是互斥的。也就是说,某时间点,对象的同步锁只能被一个线程获取到!通过同步锁,我们就能在多线程中,实现对“对象/方法”的互斥访问。 例如,现在有个线程A和线程B,它们都会访问“对象obj的同步锁”。假设,在某一时刻,线程A获取到“obj的同步锁”并在执行一些操作;而此时,线程B也企图获取“obj的同步锁” —— 线程B会获取失败,它必须等待,直到线程A释放了“该对象的同步锁”之后线程B才能获取到“obj的同步锁”从而才可以运行。

② 基本规则

第一条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的该“synchronized方法”或者“synchronized代码块”的访问将被阻塞。

第二条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程仍然可以访问“该对象”的非同步代码块。

第三条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的其他的“synchronized方法”或者“synchronized代码块”的访问将被阻塞。

③ 实例锁和全局锁

实例锁 – 锁在某一个实例对象上。如果该类是单例,那么该锁也具有全局锁的概念。实例锁对应的就是synchronized关键字。

全局锁 – 该锁针对的是类,无论实例多少个对象,那么线程都共享该锁。全局锁对应的就是static synchronized(或者是锁在该类的class或者classloader对象上)。

就是说,一个非静态方法上的synchronized关键字,代表该方法依赖其所属对象。一个静态方法上synchronized关键字,代表该方法依赖这个类本身。

六、线程通信 wait()/notify()/notifyAll()

wait():导致当前线程等待并使其进入到等待阻塞状态。直到其他线程调用该同步锁对象的notify()或notifyAll()方法来唤醒此线程。

notify():唤醒在此同步锁对象上等待的单个线程,如果有多个线程都在此同步锁对象上等待,则会任意选择其中某个线程进行唤醒操作,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程。

notifyAll():唤醒在此同步锁对象上等待的所有线程,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程。

package com.demo.test;public class Account {private String accountNo;private double balance;// 标识账户中是否已有存款private boolean flag = false;public Account() {}public Account(String accountNo, double balance) {this.accountNo = accountNo;this.balance = balance;}public String getAccountNo() {return accountNo;}public void setAccountNo(String accountNo) {this.accountNo = accountNo;}public double getBalance() {return balance;}public void setBalance(double balance) {this.balance = balance;}/*** 存钱* * @param depositeAmount*/public synchronized void deposite(double depositeAmount, int i) {if (flag) {// 账户中已有人存钱进去,此时当前线程需要等待阻塞try {System.out.println(Thread.currentThread().getName() + " 开始要执行wait操作" + " -- i=" + i);wait();// 1System.out.println(Thread.currentThread().getName() + " 执行了wait操作" + " -- i=" + i);} catch (InterruptedException e) {e.printStackTrace();}} else {// 开始存钱System.out.println(Thread.currentThread().getName() + " 存款:" + depositeAmount + " -- i=" + i);setBalance(balance + depositeAmount);flag = true;// 唤醒其他线程notifyAll();// 2try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "-- 存钱 -- 执行完毕" + " -- i=" + i);}}/*** 取钱* * @param drawAmount*/public synchronized void draw(double drawAmount, int i) {if (!flag) {// 账户中还没人存钱进去,此时当前线程需要等待阻塞try {System.out.println(Thread.currentThread().getName() + " 开始要执行wait操作" + " -- i=" + i);wait();System.out.println(Thread.currentThread().getName() + " 执行了wait操作" + " -- i=" + i);} catch (InterruptedException e) {e.printStackTrace();}} else {// 开始取钱System.out.println(Thread.currentThread().getName() + " 取钱:" + drawAmount + " -- i=" + i);setBalance(getBalance() - drawAmount);flag = false;// 唤醒其他线程notifyAll();System.out.println(Thread.currentThread().getName() + "-- 取钱 -- 执行完毕" + " -- i=" + i); // 3}}}package com.demo.test;public class DrawMoneyThread extends Thread{private Account account;private double amount;public DrawMoneyThread(String threadName, Account account, double amount) {super(threadName);this.account = account;this.amount = amount;}public void run() {for (int i = 0; i < 5; i++) {account.draw(amount, i);}}}package com.demo.test;public class DepositeMoneyThread extends Thread{private Account account;private double amount;public DepositeMoneyThread(String threadName, Account account, double amount) {super(threadName);this.account = account;this.amount = amount;}public void run() {for (int i = 0; i < 5; i++) {account.deposite(amount, i);}}}package com.demo.test;public class ThreadTest{public static void main(String[] args) {Account account = new Account("123456", 0);Thread drawMoneyThread = new DrawMoneyThread("取钱线程", account, 700);Thread depositeMoneyThread = new DepositeMoneyThread("存钱线程", account, 700);drawMoneyThread.start();depositeMoneyThread.start();}}

运行结果如下

取钱线程 开始要执行wait操作 -- i=0
存钱线程 存款:700.0 -- i=0
存钱线程-- 存钱 -- 执行完毕 -- i=0
存钱线程 开始要执行wait操作 -- i=1
取钱线程 执行了wait操作 -- i=0
取钱线程 取钱:700.0 -- i=1
取钱线程-- 取钱 -- 执行完毕 -- i=1
取钱线程 开始要执行wait操作 -- i=2
存钱线程 执行了wait操作 -- i=1
存钱线程 存款:700.0 -- i=2
存钱线程-- 存钱 -- 执行完毕 -- i=2
存钱线程 开始要执行wait操作 -- i=3
取钱线程 执行了wait操作 -- i=2
取钱线程 取钱:700.0 -- i=3
取钱线程-- 取钱 -- 执行完毕 -- i=3
取钱线程 开始要执行wait操作 -- i=4
存钱线程 执行了wait操作 -- i=3
存钱线程 存款:700.0 -- i=4
存钱线程-- 存钱 -- 执行完毕 -- i=4
取钱线程 执行了wait操作 -- i=4

由此,我们需要注意如下几点:

1.wait()方法执行后,当前线程立即进入到等待阻塞状态,其后面的代码不会执行;

2.notify()/notifyAll()方法执行后,将唤醒此同步锁对象上的(任意一个-notify()/所有-notifyAll())线程对象,但是,此时还并没有释放同步锁对象,也就是说,如果notify()/notifyAll()后面还有代码,还会继续执行,直到当前线程执行完毕才会释放同步锁对象;

3.notify()/notifyAll()执行后,如果下面有sleep()方法,则会使当前线程进入到阻塞状态,但是同步对象锁没有释放,依然自己保留,那么一定时候后还是会继续执行此线程,接下来同2;

4.wait()/notify()/nitifyAll()完成线程间的通信或协作都是基于相同对象锁的,因此,如果是不同的同步对象锁将失去意义,同时,同步对象锁最好是与共享资源对象保持一一对应关系;

5.当wait线程唤醒后并执行时,是接着上次执行到的wait()方法代码后面继续往下执行的。

当然,上面的例子相对来说比较简单,只是为了简单示例wait()/notify()/noitifyAll()方法的用法,但其本质上说,已经是一个简单的生产者-消费者模式了。

相关内容

热门资讯

【NI Multisim 14...   目录 序言 一、工具栏 🍊1.“标准”工具栏 🍊 2.视图工具...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
Android|无法访问或保存... 这个问题可能是由于权限设置不正确导致的。您需要在应用程序清单文件中添加以下代码来请求适当的权限:此外...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
AsusVivobook无法开... 首先,我们可以尝试重置BIOS(Basic Input/Output System)来解决这个问题。...
ASM贪吃蛇游戏-解决错误的问... 要解决ASM贪吃蛇游戏中的错误问题,你可以按照以下步骤进行:首先,确定错误的具体表现和问题所在。在贪...
月入8000+的steam搬砖... 大家好,我是阿阳 今天要给大家介绍的是 steam 游戏搬砖项目,目前...