how2j.cn


工具版本兼容问题
线程之间有交互通知的需求,考虑如下情况:
有两个线程,处理同一个英雄。
一个加血,一个减血。

减血的线程,发现血量=1,就停止减血,直到加血的线程为英雄加了血,才可以继续减血

本视频是解读性视频,所以希望您已经看过了本知识点的内容,并且编写了相应的代码之后,带着疑问来观看,这样收获才多。 不建议一开始就观看视频



16分9秒
本视频采用html5方式播放,如无法正常播放,请将浏览器升级至最新版本,推荐火狐,chrome,360浏览器 如果装有迅雷,播放视频呈现直接下载状态,请调整 迅雷系统设置-基本设置-启动-监视全部浏览器 (去掉这个选项)



步骤 1 : 不好的解决方式   
步骤 2 : 使用wait和notify进行线程交互   
步骤 3 : 关于wait、notify和notifyAll   
步骤 4 : 练习-线程交互   
步骤 5 : 答案-线程交互   
步骤 6 : 练习-多线程交互   
步骤 7 : 答案-多线程交互   
步骤 8 : 练习-生产者消费者问题   
步骤 9 : 答案-生产者消费者问题   

步骤 1 :

不好的解决方式

故意设计减血线程频率更高,盖伦的血量迟早会到达1
减血线程中使用while循环判断是否是1,如果是1就不停的循环,直到加血线程回复了血量
这是不好的解决方式,因为会大量占用CPU,拖慢性能
package charactor; public class Hero{ public String name; public float hp; public int damage; public synchronized void recover(){ hp=hp+1; } public synchronized void hurt(){ hp=hp-1; } public void attackHero(Hero h) { h.hp-=damage; System.out.format("%s 正在攻击 %s, %s的血变成了 %.0f%n",name,h.name,h.name,h.hp); if(h.isDead()) System.out.println(h.name +"死了!"); } public boolean isDead() { return 0>=hp?true:false; } }
package multiplethread; import java.awt.GradientPaint; import charactor.Hero; public class TestThread { public static void main(String[] args) { final Hero gareen = new Hero(); gareen.name = "盖伦"; gareen.hp = 616; Thread t1 = new Thread(){ public void run(){ while(true){ //因为减血更快,所以盖伦的血量迟早会到达1 //使用while循环判断是否是1,如果是1就不停的循环 //直到加血线程回复了血量 while(gareen.hp==1){ continue; } gareen.hurt(); System.out.printf("t1 为%s 减血1点,减少血后,%s的血量是%.0f%n",gareen.name,gareen.name,gareen.hp); try { Thread.sleep(10); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t1.start(); Thread t2 = new Thread(){ public void run(){ while(true){ gareen.recover(); System.out.printf("t2 为%s 回血1点,增加血后,%s的血量是%.0f%n",gareen.name,gareen.name,gareen.hp); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t2.start(); } }
步骤 2 :

使用wait和notify进行线程交互

在Hero类中:hurt()减血方法:当hp=1的时候,执行this.wait().
this.wait()表示 让占有this的线程等待,并临时释放占有
进入hurt方法的线程必然是减血线程,this.wait()会让减血线程临时释放对this的占有。 这样加血线程,就有机会进入recover()加血方法了


recover() 加血方法:增加了血量,执行this.notify();
this.notify() 表示通知那些等待在this的线程,可以苏醒过来了。 等待在this的线程,恰恰就是减血线程。 一旦recover()结束, 加血线程释放了this,减血线程,就可以重新占有this,并执行后面的减血工作。
使用wait和notify进行线程交互
package charactor; public class Hero { public String name; public float hp; public int damage; public synchronized void recover() { hp = hp + 1; System.out.printf("%s 回血1点,增加血后,%s的血量是%.0f%n", name, name, hp); // 通知那些等待在this对象上的线程,可以醒过来了,如第20行,等待着的减血线程,苏醒过来 this.notify(); } public synchronized void hurt() { if (hp == 1) { try { // 让占有this的减血线程,暂时释放对this的占有,并等待 this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } hp = hp - 1; System.out.printf("%s 减血1点,减少血后,%s的血量是%.0f%n", name, name, hp); } public void attackHero(Hero h) { h.hp -= damage; System.out.format("%s 正在攻击 %s, %s的血变成了 %.0f%n", name, h.name, h.name, h.hp); if (h.isDead()) System.out.println(h.name + "死了!"); } public boolean isDead() { return 0 >= hp ? true : false; } }
package multiplethread; import java.awt.GradientPaint; import charactor.Hero; public class TestThread { public static void main(String[] args) { final Hero gareen = new Hero(); gareen.name = "盖伦"; gareen.hp = 616; Thread t1 = new Thread(){ public void run(){ while(true){ //无需循环判断 // while(gareen.hp==1){ // continue; // } gareen.hurt(); try { Thread.sleep(10); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t1.start(); Thread t2 = new Thread(){ public void run(){ while(true){ gareen.recover(); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t2.start(); } }
步骤 3 :

关于wait、notify和notifyAll

留意wait()和notify() 这两个方法是什么对象上的?


public synchronized void hurt() {
。。。
this.wait();
。。。
}


public synchronized void recover() {
。。。
this.notify();
}


这里需要强调的是,wait方法和notify方法,并不是Thread线程上的方法,它们是Object上的方法。

因为所有的Object都可以被用来作为同步对象,所以准确的讲,wait和notify是同步对象上的方法。

wait()的意思是: 让占用了这个同步对象的线程,临时释放当前的占用,并且等待。 所以调用wait是有前提条件的,一定是在synchronized块里,否则就会出错。

notify() 的意思是,通知一个等待在这个同步对象上的线程,可以苏醒过来了,有机会重新占用当前对象了。

notifyAll() 的意思是,通知所有的等待在这个同步对象上的线程,你们可以苏醒过来了,有机会重新占用当前对象了。
步骤 4 :

练习-线程交互

Or  姿势不对,事倍功半! 点击查看做练习的正确姿势
假设加血线程运行得更加频繁,英雄的最大血量是1000

设计加血线程和减血线程的交互,让回血回满之后,加血线程等待,直到有减血线程减血
步骤 5 :

答案-线程交互

在查看答案前,尽量先自己完成,碰到问题再来查看答案,收获会更多
本视频是解读性视频,所以希望您已经看过了本答案的内容,带着疑问来观看,这样收获才多。 不建议一开始就观看视频

3分11秒 本视频采用html5方式播放,如无法正常播放,请将浏览器升级至最新版本,推荐火狐,chrome,360浏览器 如果装有迅雷,播放视频呈现直接下载状态,请调整 迅雷系统设置-基本设置-启动-监视全部浏览器 (去掉这个选项)


package charactor; public class Hero { public String name; public float hp; public int damage; public synchronized void recover() { //当血量大于或者等于1000的时候 //this.wait() 让占用这个对象的线程等待,并临时释放锁 if(hp>=1000){ try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } hp = hp + 1; System.out.printf("%s 回血1点,增加血后,%s的血量是%.0f%n", name, name, hp); this.notify(); } public synchronized void hurt() { if (hp == 1) { try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } hp = hp - 1; System.out.printf("%s 减血1点,减少血后,%s的血量是%.0f%n", name, name, hp); //掉血之后,唤醒等待的线程 this.notify(); } public void attackHero(Hero h) { h.hp -= damage; System.out.format("%s 正在攻击 %s, %s的血变成了 %.0f%n", name, h.name, h.name, h.hp); if (h.isDead()) System.out.println(h.name + "死了!"); } public boolean isDead() { return 0 >= hp ? true : false; } }
package multiplethread; import charactor.Hero; public class TestThread { public static void main(String[] args) { final Hero gareen = new Hero(); gareen.name = "盖伦"; gareen.hp = 616; Thread t1 = new Thread(){ public void run(){ while(true){ gareen.hurt(); try { //减慢掉血的速度 Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t1.start(); Thread t2 = new Thread(){ public void run(){ while(true){ gareen.recover(); try { //加快回血的速度 Thread.sleep(10); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }; t2.start(); } }
步骤 6 :

练习-多线程交互

Or  姿势不对,事倍功半! 点击查看做练习的正确姿势
在上面的练习的基础上,增加回血线程到2条,减血线程到5条,同时运行。

运行一段时间,观察会发生的错误,分析错误原因,并考虑解决办法
步骤 7 :

答案-多线程交互

在查看答案前,尽量先自己完成,碰到问题再来查看答案,收获会更多
本视频是解读性视频,所以希望您已经看过了本答案的内容,带着疑问来观看,这样收获才多。 不建议一开始就观看视频

9分55秒 本视频采用html5方式播放,如无法正常播放,请将浏览器升级至最新版本,推荐火狐,chrome,360浏览器 如果装有迅雷,播放视频呈现直接下载状态,请调整 迅雷系统设置-基本设置-启动-监视全部浏览器 (去掉这个选项)


在目前的状态下,会导致英雄的血量变为负数。 这是因为减血线程调用hurt() 方法结束时,调用notify,有可能会唤醒另一个减血线程,这就导致不停的减血。

解决办法是: 减血线程被唤醒后,要再次查看当前血量,如果当前血量<=1,那么就继续等待

//把if改为while,被唤醒后,会重复查看hp的值,只有hp大于1,才会往下执行


//把if改为while,被唤醒后,会重复查看hp的值,只有hp大于1,才会往下执行
//if(hp <= 1) {
while (hp <= 1) {
try {
this.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
答案-多线程交互
package charactor; public class Hero { public String name; public float hp; public int damage; public synchronized void recover() { //当血量大于或者等于1000的时候 //this.wait() 让占用这个对象的线程等待,并临时释放锁 while(hp>=1000){ try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } hp = hp + 1; System.out.printf("%s 回血1点,增加血后,%s的血量是%.0f%n", name, name, hp); this.notify(); } public synchronized void hurt() { //把if改为while,被唤醒后,会重复查看hp的值,只有hp大于1,才会往下执行 //if (hp <= 1) { while (hp <= 1) { try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } hp = hp - 1; System.out.printf("%s 减血1点,减少血后,%s的血量是%.0f%n", name, name, hp); //掉血之后,唤醒等待的线程 this.notify(); } public void attackHero(Hero h) { h.hp -= damage; System.out.format("%s 正在攻击 %s, %s的血变成了 %.0f%n", name, h.name, h.name, h.hp); if (h.isDead()) System.out.println(h.name + "死了!"); } public boolean isDead() { return 0 >= hp ? true : false; } }
package multiplethread; import charactor.Hero; public class TestThread { static class HurtThread extends Thread{ private Hero hero; public HurtThread(Hero hero){ this.hero = hero; } public void run(){ while(true){ hero.hurt(); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } } static class RecoverThread extends Thread{ private Hero hero; public RecoverThread(Hero hero){ this.hero = hero; } public void run(){ while(true){ hero.recover(); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } } public static void main(String[] args) { final Hero gareen = new Hero(); gareen.name = "盖伦"; gareen.hp = 616; for (int i = 0; i < 2; i++) { new RecoverThread(gareen).start(); } for (int i = 0; i < 5; i++) { new HurtThread(gareen).start(); } } }
步骤 8 :

练习-生产者消费者问题

Or  姿势不对,事倍功半! 点击查看做练习的正确姿势
生产者消费者问题是一个非常典型性的线程交互的问题。

1. 使用来存放数据
1.1 把栈改造为支持线程安全
1.2 把栈的边界操作进行处理,当栈里的数据是0的时候,访问pull的线程就会等待。 当栈里的数据时200的时候,访问push的线程就会等待
2. 提供一个生产者(Producer)线程类,生产随机大写字符压入到堆栈
3. 提供一个消费者(Consumer)线程类,从堆栈中弹出字符并打印到控制台
4. 提供一个测试类,使两个生产者和三个消费者线程同时运行,结果类似如下 :
练习-生产者消费者问题
步骤 9 :

答案-生产者消费者问题

在查看答案前,尽量先自己完成,碰到问题再来查看答案,收获会更多
本视频是解读性视频,所以希望您已经看过了本答案的内容,带着疑问来观看,这样收获才多。 不建议一开始就观看视频

8分54秒 本视频采用html5方式播放,如无法正常播放,请将浏览器升级至最新版本,推荐火狐,chrome,360浏览器 如果装有迅雷,播放视频呈现直接下载状态,请调整 迅雷系统设置-基本设置-启动-监视全部浏览器 (去掉这个选项)


package multiplethread; import java.util.ArrayList; import java.util.LinkedList; public class MyStack<T> { LinkedList<T> values = new LinkedList<T>(); public synchronized void push(T t) { while(values.size()>=200){ try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } this.notifyAll(); values.addLast(t); } public synchronized T pull() { while(values.isEmpty()){ try { this.wait(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } this.notifyAll(); return values.removeLast(); } public T peek() { return values.getLast(); } }
package multiplethread; public class ProducerThread extends Thread{ private MyStack<Character> stack; public ProducerThread(MyStack<Character> stack,String name){ super(name); this.stack =stack; } public void run(){ while(true){ char c = randomChar(); System.out.println(this.getName()+" 压入: " + c); stack.push(c); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } public char randomChar(){ return (char) (Math.random()*('Z'+1-'A') + 'A'); } }
package multiplethread; public class ConsumerThread extends Thread{ private MyStack<Character> stack; public ConsumerThread(MyStack<Character> stack,String name){ super(name); this.stack =stack; } public void run(){ while(true){ char c = stack.pull(); System.out.println(this.getName()+" 弹出: " + c); try { Thread.sleep(100); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } public char randomChar(){ return (char) (Math.random()*('Z'+1-'A') + 'A'); } }
package multiplethread; public class TestThread { public static void main(String[] args) { MyStack<Character> stack = new MyStack<>(); new ProducerThread(stack, "Producer1").start(); new ProducerThread(stack, "Producer2").start(); new ConsumerThread(stack, "Consumer1").start(); new ConsumerThread(stack, "Consumer2").start(); new ConsumerThread(stack, "Consumer3").start(); } }


HOW2J公众号,关注后实时获知布最新的教程和优惠活动,谢谢。


问答区域    
2018-11-28 消费者和生产者作业
WANGMING123



用到Thread.currentThread().getName()来命名不同的线程名
package newking;

import java.lang.reflect.Constructor;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;

class stack<T>
{
    List<T> p;
    public stack()
    {
        List<T> r=new LinkedList<>();
        p= Collections.synchronizedList(r);
    }
    public synchronized void push(T k) throws InterruptedException {
        while (p.size()==200)this.wait();
        p.add(k);
    }
    public synchronized T pull()
    {
        if (p.size()<=1)this.notify();
        return p.remove(p.size()-1);
    }
}
class Producer implements Runnable
{
    stack<Character> p;
    public Producer(stack<Character> p)
    {
        this.p=p;
    }
    @Override
    public void run() {
        Random e=new Random();
        char h;
        while (true)
    {
            h = (char) (e.nextInt(26)+'A');

        try {
            System.out.println(Thread.currentThread().getName()+"  压入:"+h);
            p.push(h);
            Thread.sleep(10);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }
    }
}
class Consumer implements Runnable
{
    stack<Character> p;
    public Consumer(stack<Character> p)
    {
        this.p=p;
    }
    @Override
    public void run() {
      while (true)
      {
          System.out.println(Thread.currentThread().getName()+"  弹出:"+p.pull());
          try {
              Thread.sleep(100);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
    }
}
public class TEST {
    public static void main(String[] args) throws InterruptedException {
    stack<Character> t=new stack<>();
        for(int i=0;i<2;i++)
        {
            new Thread(new Producer(t),"Producer"+(i+1)).start();
        }
        Thread.sleep(200);
        for(int i=0;i<3;i++)
        {
            new Thread(new Consumer(t),"Consumer"+(i+1)).start();
        }
    }
}

							






答案 或者 代码至少填写一项, 如果是自己有问题,请重新提问,否则站长有可能看不到





2018-11-28 生产者和消费者问题作业
WANGMING123



package newking; import java.lang.reflect.Constructor; import java.nio.charset.Charset; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Random; class stack<T> { List<T> p; public stack() { List<T> r=new LinkedList<>(); p= Collections.synchronizedList(r); } public synchronized void push(T k) throws InterruptedException { while (p.size()==200)this.wait(); p.add(k); } public synchronized T pull() { if (p.size()==1)this.notify(); return p.remove(p.size()-1); } } class Producer implements Runnable { stack<Character> p; public Producer(stack<Character> p) { this.p=p; } @Override public void run() { Random e=new Random(); char h; while (true) { while (true) { h = (char) e.nextInt(500); if(Character.isLowerCase(h)||Character.isUpperCase(h)) { break; } } try { System.out.println(Thread.currentThread().getName()+" 压入:"+h); p.push(h); Thread.sleep(10); } catch (InterruptedException e1) { e1.printStackTrace(); } } } } class Consumer implements Runnable { stack<Character> p; public Consumer(stack<Character> p) { this.p=p; } @Override public void run() { while (true) { System.out.println(Thread.currentThread().getName()+" 弹出:"+p.pull()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public class TEST { public static void main(String[] args) throws InterruptedException { stack<Character> t=new stack<>(); for(int i=0;i<2;i++) { new Thread(new Producer(t),"Producer"+(i+1)).start(); } Thread.sleep(200); for(int i=0;i<3;i++) { new Thread(new Consumer(t),"Consumer"+(i+1)).start(); } } }
package newking;

import java.lang.reflect.Constructor;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;

class stack<T>
{
    List<T> p;
    public stack()
    {
        List<T> r=new LinkedList<>();
        p= Collections.synchronizedList(r);
    }
    public synchronized void push(T k) throws InterruptedException {
        while (p.size()==200)this.wait();
        p.add(k);
    }
    public synchronized T pull()
    {
        if (p.size()==1)this.notify();
        return p.remove(p.size()-1);
    }
}
class Producer implements Runnable
{
    stack<Character> p;
    public Producer(stack<Character> p)
    {
        this.p=p;
    }
    @Override
    public void run() {
        Random e=new Random();
        char h;
        while (true)
    {
        while (true) {
            h = (char) e.nextInt(500);
            if(Character.isLowerCase(h)||Character.isUpperCase(h))
            {
                break;
            }
        }
        try {
            System.out.println(Thread.currentThread().getName()+"  压入:"+h);
            p.push(h);
            Thread.sleep(10);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }
    }
}
class Consumer implements Runnable
{
    stack<Character> p;
    public Consumer(stack<Character> p)
    {
        this.p=p;
    }
    @Override
    public void run() {
      while (true)
      {
          System.out.println(Thread.currentThread().getName()+"  弹出:"+p.pull());
          try {
              Thread.sleep(100);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
    }
}
public class TEST {
    public static void main(String[] args) throws InterruptedException {
    stack<Character> t=new stack<>();
        for(int i=0;i<2;i++)
        {
            new Thread(new Producer(t),"Producer"+(i+1)).start();
        }
        Thread.sleep(200);
        for(int i=0;i<3;i++)
        {
            new Thread(new Consumer(t),"Consumer"+(i+1)).start();
        }
    }
}

							






答案 或者 代码至少填写一项, 如果是自己有问题,请重新提问,否则站长有可能看不到





2018-11-23 题8生产消费线程问题
2018-10-13 生产者消费者作业
2018-09-13 多线程交互的一个问题
2018-09-07 生产者消费者问题作业
2018-09-05 关于代码重名问题
2018-08-11 关于this.notify()和this.notifyAll()的问题
2018-07-25 多线程交互
2018-07-17 写Stack类时用的Collections的synchronizeList方法,结果报illegalmonitorstateexception异常
2018-06-21 同一线程内的notify为何不会唤醒本线程内之前的wait?
2018-06-21 同一线程内的notify为何不会唤醒本线程内之前的wait?
2018-06-02 我想问一下实现Runnable接口怎么修改线程的名字呢
2018-05-30 减血的线程有更大概率原因
2018-05-16 我自己写的代码也出现了之前同学说的减到1之后一直增的情况,我发现的问题是这样的
2018-05-14 生产者消费者问题抛出空指针异常
2018-05-08 hp降为1后就不再减hp,一直都是加hp
2018-04-23 在加血线程和减血线程中都加入notify()后,用while代替if后,就用了不太好的方式,因为会大量占用CPU,拖慢性能,是不是?逻辑应该是这样
2018-04-23 生产者消费者问题
2018-04-19 debug了一下,有新的问题
2018-04-19 为啥和站长写的一样,只受伤不治疗呢
2018-04-02 交作业
2018-03-21 sleep方法为什么不能放到hurt和recover的方法里面?
2017-12-22 使用站长的代码,发现减血到1之后就一直增加了。
2017-12-14 步骤1代码执行后,盖伦的生命值降到1之后,血量只升不降
2017-12-07 生产者消费者问题
2017-11-30 生产者消费者问题
2017-11-23 关于生产者消费者问题的线程安全的MyStack类的疑惑
2017-10-24 在ProducerThread代码中有一小点逻辑问题
2017-09-16 关于notify()和notifyAll()
2017-08-24 步骤1不好的解决方式代码,如果continue上面不设置响应时间,线程t1会退出,到最后不能加血减血同步进行,只能看到加血
2017-08-20 多线程交互的练习题问题
2017-08-19 notify 用不用的问题
2017-08-17 出现异常 但还是能运行
2017-07-04 还有一个关于逻辑运算速度的问题
2017-07-04 不明白为什么要 import java.awt.GradientPaint;
2017-06-17 用while解决,好像并没有解决
2017-05-18 站长帮忙看一下 我的代码不知道为什么 当血量降到1以后 就一直加血不再减血了-.-
2017-04-07 生产者消费者问题
2017-04-04 MyStack.java
2017-02-23 不明白为啥if换成while就没问题了




提问之前请登陆
关于 JAVA 中级-多线程-交互 的提问

尽量提供截图代码异常信息,有助于分析和解决问题。 也可进本站QQ群交流: 902680467
提问尽量提供完整的代码,环境描述,越是有利于问题的重现,您的问题越能更快得到解答。
对教程中代码有疑问,请提供是哪个步骤,哪一行有疑问,这样便于快速定位问题,提高问题得到解答的速度
在已经存在的几千个提问里,有相当大的比例,是因为使用了和站长不同版本的开发环境导致的,比如 jdk, eclpise, idea, mysql,tomcat 等等软件的版本不一致。
请使用和站长一样的版本,可以节约自己大量的学习时间。 站长把教学中用的软件版本整理了,都统一放在了这里, 方便大家下载: http://how2j.cn/k/helloworld/helloworld-version/1718.html

上传截图