(十八)多线程

Posted wuchao0508

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了(十八)多线程相关的知识,希望对你有一定的参考价值。

多线程

多线程与多进程的区别在于每个进程拥有自己的一整套变量,线程则共享数据。与进程相比,线程更加“轻量级”,创建和撤销一个线程比启动新进程开销要小得多。

 

实现多线程有两种方法:

  1. 实现Runnable接口
  2. 继承Thread类

以下采用两种方法分别实现多线程

实现Runnable接口

public class Football implements Runnable
    private String player;
    private static int steps=10;
    public Football(String name)
        player = name;
    
    @Override
    public void run() 
        while(true)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(1200);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    


public class Basketball implements Runnable 
    private String player;
    private static int steps=20;
    public Basketball(String name)
        player = name;
    
    @Override
    public void run() 
        while(true)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing basketball");
            try 
                Thread.currentThread();
                Thread.sleep(700);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
        
    



public class Play 

    public static void main(String[] args) 
        // TODO Auto-generated method stub
        Runnable foot = new Football("Tom");
        Runnable basket = new Basketball("John");
        
        Thread t1 = new Thread(foot);
        Thread t2 = new Thread(basket);
        
        t1.start();
        t2.start();
    



输出:
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball

继承Thread类

public class Football extends Thread
    private String player;
    public Football(String name)
        player = name;
    
    public void run()
        while(true)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(1200);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    





public class Basketball extends Thread
    private String player;
    public Basketball(String name)
        player = name;
    
    public void run()
        while(true)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing basketball");
            try 
                Thread.currentThread();
                Thread.sleep(700);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    




public class Play 

    public static void main(String[] args) 
        // TODO Auto-generated method stub
        Thread t1 = new Football("Tom");
        Thread t2 = new Basketball("John");
        
        t1.start();
        t2.start();
    




输出:
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
8:Tom is playing football

实现Runnable接口比继承Thread类所具有的优势:

1):适合多个相同的程序代码的线程去处理同一个资源

2):可以避免java中的单继承的限制

3):增加程序的健壮性,代码可以被多个线程共享,代码和数据独立

具体见这里

中断线程

当线程的run方法执行到结尾,或者出现未捕获的异常便会停止线程。

可以通过Thread.currentThread().interrupt()来强制终止当前线程。当对一个线程调用该方法时,线程的中断状态会被置位。我们便可以通过Thread.currentThread().isInterrupted()检查这个标志判断线程是否被中断。

但是,如果线程被阻塞(调用sleep或wait),就无法检测中断状态。当一个被阻塞的方法调用interrupt方法时,会被InterruptedException异常中断。

当中断状态被被置位时,调用sleep方法,他不会休眠,而是清除这一状态并抛出InterruptedException异常。

//更改Football部分代码
public class Football implements Runnable
    private String player;
    private static int steps=10;
    public Football(String name)
        player = name;
    
    @Override
    public void run() 
        while(!Thread.currentThread().isInterrupted())
            steps--;
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(800);
             catch (InterruptedException e) e.printStackTrace();
            
            if(steps<=0)
                Thread.currentThread().interrupt();//设置中断标志
                try 
                    Thread.sleep(800);//前面设置了中断标志,因此此处调用会发生InterruptedException异常
                 catch (InterruptedException e)  //发生InterruptedException异常后,中断标志会被清除
                    System.out.println("vvvvvvvvvvvvvvvvvvvvvvvv");
                    System.out.println("线程:"+Thread.currentThread().getId()+" 调用sleep方法发生异常!");
                    try 
                        Thread.sleep(800); //因为中断标志被清除了,因此这里调用没问题
                        System.out.println("线程:"+Thread.currentThread().getId()+" 调用sleep方法成功!");
                     catch (InterruptedException e1) 
                    System.out.println("^^^^^^^^^^^^^^^^^^^^^^^^");
                
            
        
        System.out.println("Footbal线程被终止!");
    

输出

9:John is playing basketball
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
8:Tom is playing football
9:John is playing basketball
vvvvvvvvvvvvvvvvvvvvvvvv
线程:8 调用sleep方法发生异常!
9:John is playing basketball
线程:8 调用sleep方法成功!
^^^^^^^^^^^^^^^^^^^^^^^^
8:Tom is playing football
9:John is playing basketball

线程状态

线程有6种状态

    1. 新创建
    2. 可运行
    3. 被阻塞
    4. 等待
    5. 计时等待
    6. 被终止

五个阶段:创建、就绪、运行、阻塞、终止

新建线程

new Thread(r)新建一个线程,但还没有运行。

可运行线程

调用start()方法,线程处于runnable状态。可能在运行也可能没有运行。取决于系统。

阻塞线程

当一个线程试图获取一个内部的对象锁,而该锁被其他线程所持有,则该线程处于阻塞状态。当其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程变成非阻塞状态。

等待线程

当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。在调用Object.wait方法或Thread.join方法,,或是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。

计时等待

有几个方法有一个超时参数,调用它们会导致线程进入计时等待。比如Thread.sleep(),Object.wait,Thread.join,Lock.tryLock,Condition.await。

终止线程

以下两种可能:

因为run方法正常退出而死亡。

因为一个未被捕获的异常而终止了run方法而意外死亡。

可以调用Thread.currentThread().join()或Thread.currentThread().join(long millis)来等待进程的终止。

线程状态图

 

 

 

线程状态转换

线程属性

线程优先级

当线程调度器有机会选择新线程的时候,它会首先选择具有较高优先级的线程。

守护线程

t.setDaemon(true)将线程设置为守护线程。用途是为其他线程提供服务。虚拟机不需要等待守护线程结束才退出,当只剩下守护线程时,虚拟机就会自动退出。

1)、thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。  

2)、 在Daemon线程中产生的新线程也是Daemon的。 

3)、不是所有的应用都可以分配给Daemon线程来进行服务,比如读写操作或者计算逻辑。因为在Daemon Thread还没来的及进行操作时,虚拟机可能已经退出了。

举个例子,web服务器中的Servlet,容器启动时后台初始化一个服务线程,即调度线程,负责处理http请求,然后每个请求过来调度线程从线程池中取出一个工作者线程来处理该请求,从而实现并发控制的目的。

未捕获异常处理器

线程的run方法不能抛出任何被检测的异常,但是不被检测的异常会导致线程终止。

但是不需要任何catch子句来处理可以被传播的异常。相反,就在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。

该处理器必须实现了Thread.UncaughtExceptionHandler接口的类。该接口有一个方法uncaughtException(Thread t,Throwable e)。

也可以使用Thread.setDefaultUncaughtException为所有线程设置默认处理器。

如果不安装默认的处理器,则默认的处理器为空。

如果不为独立的线程安装处理器,则此时的处理器就是线程的ThreadGroup对象。

ThreadGroup类实现了Thread.UncaughtExceptionHandler接口,它的uncaughtException方法操作如下:

1、如果该线程组有父线程组,那么调用父线程组的uncaughtException方法

2、否则,如果Thread.getDefaultUncaughtException返回非空处理器,则调用

3、否则,如果Throwable是ThreadDeath的一个实例,什么也不做

4、否则,线程的名字以及Throwable的栈踪迹被输出到System.err上

个人理解:已检查异常在run方法中使用catch可以直接捕获,未检查异常无法捕获,但为了线程得到妥善处理,必须设置一个处理器处理这种异常。

举例:

在Football类的run方法人为的添加一个异常,在main方法设置对应线程的处理器

public class Football implements Runnable
    private String player;
    private static int steps=10;
    public Football(String name)
        player = name;
    
    @Override
    public void run() 
        while(Thread.currentThread().isAlive())
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(800);
                double a = 12/0;//人为的设置未检查异常
             catch (InterruptedException e) e.printStackTrace();
        
    
public class Play 

    public static void main(String[] args) 
        // TODO Auto-generated method stub
        Runnable foot = new Football("Tom");
        Runnable basket = new Basketball("John");
        
        Thread t1 = new Thread(foot);
        Thread t2 = new Thread(basket);
        t1.setUncaughtExceptionHandler(new UncaughtExceptionHandler()   //为t1线程设置为检查异常的处理器
            public void uncaughtException(Thread t, Throwable e) 
                System.out.println("线程 "+t1.getId()+" 出现异常,使用处理器处理。");
            
        );
        t1.start();
        t2.start();
    


输出:
8:Tom is playing football
9:John is playing basketball
9:John is playing basketball
线程Thread 8 出现异常,使用处理器处理。
9:John is playing basketball
9:John is playing basketball
9:John is playing basketball
9:John is playing basketball
9:John is playing basketball

同步

当多个线程需要共享对同一个数据的存取。可能会发生数据的混乱。

比如银行有多个账户,多个进程处理账户之间的转账。使用一个数组accounts表示账户,每个元素的值代表该账户的金额,使用多线程进行转账,程序如下:

竞争条件的一个例子

定义一个银行类:

public class Bank 
    private final Double[] accounts;//存放账户资金
    public Bank(int n,double initialBalance)//账户数目,每个账户的金额
        accounts = new Double[n];
        for(int i=0;i<n;i++)
            accounts[i]=initialBalance;
        
    
    
    public void transfer(int from,int to ,double acc)//转账
        if(accounts[from]<acc) return;
        System.out.println(Thread.currentThread()+" 从账户"+from+"转账到"+to+":"+acc);
        accounts[from]-=acc;
        accounts[to]+=acc;
        System.out.println("账户总金额:"+getTotalBanlance());
    
    
    public double getTotalBanlance()//获取银行总金额
        double n=0;
        for(int i=0;i<accounts.length;i++)
            n+=accounts[i];
        
        return n;
    
    public int getSize()
        return accounts.length;
    

定义一个线程类,处理转账业务

public class TransferRunnable implements Runnable 
    private Bank bank;
    private int from;
    public TransferRunnable(Bank b,int from)
        bank=b;
        this.from=from;
    
    @Override
    public void run() 
        // TODO Auto-generated method stub
        try 
            while(true)
                int to = (int) (Math.random()*bank.getSize());
                double amount =  Math.random()*1000;
                bank.transfer(from, to, amount);
                Thread.sleep((int)Math.random()*10);
            
         catch (InterruptedException e) e.printStackTrace();
    
    

开启多线程

public class BankTest 
    public static final int NACCOUNTS = 100;
    public static final double INITIAL_BANLANCE = 1000;
    public static void main(String[] args) 
        Bank bank = new Bank(NACCOUNTS,INITIAL_BANLANCE);
        for(int i=0;i<NACCOUNTS;i++)
            TransferRunnable r = new TransferRunnable(bank,i);
            Thread t = new Thread(r);
            t.start();
        

    



输出:
...
...
账户总金额:99971.24543560264
Thread[Thread-24,5,main] 从账户24转账到32:0.8267081194558434
账户总金额:99971.24543560264
Thread[Thread-23,5,main] 从账户23转账到80:0.7182847567358541
账户总金额:99971.24543560264
Thread[Thread-69,5,main] 从账户69转账到1:0.8721198327459323
账户总金额:99971.24543560264
Thread[Thread-83,5,main] 从账户83转账到71:0.6914852979901243
账户总金额:99971.24543560267
Thread[Thread-81,5,main] 从账户81转账到11:0.9059345501664096
账户总金额:99971.24543560267
Thread[Thread-78,5,main] 从账户78转账到26:0.26182652232631387

可以看到,当多线程对同一个数组accounts进行读写时,会发生数据异常。

锁对象

有两种机制可以阻止代码受并发的访问。ReentrantLock类和synchronized关键字

(一)ReentrantLock类

Lock myLock = new ReentrantLock();
myLock.lock();
try
       //代码块

finally
    myLock.unlock();

这一结构保证任意时刻都只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他线程都无法通过lock语句。当其他线程调用lock时将被阻塞,直到第一个线程释放锁对象。

下面举一个简单的例子

不使用锁的情况

public class Football implements Runnable
    private String player;
    private int steps=10;
    
    public Football(String name)
        player = name;
    
    @Override
    public void run() 
        print();
    
    
    public void print()
        int n = 10;
        while((n--)>0)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(800);
             catch (InterruptedException e) e.printStackTrace();
        
    




public class Play 
    public static void main(String[] args) 
        // TODO Auto-generated method stub
        Runnable foot = new Football("Tom");
        
        Thread t1 = new Thread(foot);
        Thread t2 = new Thread(foot);
        t1.start();
        t2.start();
    


输出:
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
8:Tom is playing football
9:Tom is playing football
9:Tom is playing football
8:Tom is playing football
8:Tom is playing football
9:Tom is playing football

使用锁

public class Football implements Runnable
    private String player;
    private int steps=10;
    
    private Lock myLock = new ReentrantLock();
    
    public Football(String name)
        player = name;
    
    @Override
    public void run() 
        print();
    
    
    public void print()
        int n = 10;
        System.out.println(Thread.currentThread().getId()+":"+"try to lock");
        myLock.lock();
        while((n--)>0)
            System.out.println(Thread.currentThread().getId()+":"+player+" is playing football");
            try 
                Thread.currentThread();
                Thread.sleep(800);
             catch (InterruptedException e) e.printStackTrace();
        
        System.out.println(Thread.currentThread().getId()+":"+"try to unlock");
        myLock.unlock();
    




public class Play 
    public static void main(String[] args) 
        // TODO Auto-generated method stub
        Runnable foot = new Football("Tom");
        
        Thread t1 = new Thread(foot);
        Thread t2 = new Thread(foot);
        t1.start();
        t2.start();
    


输出:
8:try to lock
8:Tom is playing football
9:try to lock
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:Tom is playing football
8:try to unlock
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:Tom is playing football
9:try to unlock

如上所示,当不使用锁时,当两个线程对同一个foot对象执行run方法时,它们都调用print方法,因此两个print方法会同时运行,于是我们可以看到交替打印的信息。

而使用锁对象后,因为每个foot对象都只有一个锁对象,当线程t1先运行时,会首先执行print方法,在方法内获取锁对象。然后执行打印语句。当线程t2调用print方法,会尝试获取锁对象,而锁对象已经被线程t1获取,因此进入阻塞状态。当线程t1结束方法print的调用后,释放锁对象。线程t2的print方法才得以继续执行下去。

每个Football对象都有自己的ReentrantLock对象。如果两个线程试图访问同一个Football对象,那么锁以串行方式提供服务。但是如果两个线程访问的是不同的Football对象,每个线程得到不同的锁对象,两个线程不会发生阻塞。

锁是可重入的,即每个线程可以重复的获取已经持有的锁,锁保持一个持有计数来跟踪对lock方法的嵌套使用。被一个锁保护的代码可以调用另一个使用相同锁的方法。当一个线程第一次持有锁对象后,计数器计为1,当该线程执行其他被同一个锁对象保护的代码时,计数器加1。每一次释放锁,计数器减1,当计数器为0时,认为线程完全释放该锁对象。

条件对象

当线程进入临界区(即被锁对象保护的代码块),却发现只有当某一个条件满足时才能继续下去。此时需要一个条件对象来管理那些持有锁对象却不能继续执行的线程。

一个锁对象锁可以拥有多个条件对象。

以上面银行转账为例,transfer方法应该被锁对象保护,当转账时发现账户余额不足,则条件对象调用await()进入阻塞状态,当有线程调用同一条件上的signalAll方法时,重新激活所有因为这一条件而进入等待的所有线程。

public class Bank 
    ...
    ...
    private Lock bankLock = new ReentrantLock();
    private Condition condition = bankLock.newCondition();//锁的条件对象
    ...
    ...
    public void transfer(int from,int to ,double acc) throws InterruptedException//转账
        bankLock.lock();
        try
            while(accounts[from]<acc) //当条件满足时,进入等待集,阻塞状态
                condition.await();
            
            accounts[from]-=acc;
            accounts[to]+=acc;
            condition.signalAll();//当账户余额变动后,通知所有因为该条件对象而等待的线程退出等待集,称为可运行的线程,等待调度器调用。当调度器调用该线程后,试图获取锁对象,获取成功后,从阻塞的地方继续执行。
        
        finally
            bankLock.unlock();
        
    
    ...
    ...

当一个线程拥有某个条件的锁时,它仅仅可以在该条件上使用await,signalAll或signall方法。

(二)synchronized关键字

前面Lock和Condition接口为我们提供了高度的锁定控制。然而大多数情况下,并不需要那样的控制。可以使用一种嵌入到Java语言内部的机制。Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将会保护整个方法。要调用该方法,线程必须获得内部的对象。

内部对象锁只有一个条件对象(不同于之前的ReentrantLock的对象锁可以生成多个条件对象),wait方法添加一个线程到等待集中,notifyAll()或notify()解除等待线程的阻塞状态。

上面代码更改如下

public class Bank 
    ...
    ...
    private Lock bankLock = new ReentrantLock();
    ...
    ...
    public synchronized void transfer(int from,int to ,double acc) throws InterruptedException//转账
           while(accounts[from]<acc) 
                await();
                accounts[from]-=acc;
                accounts[to]+=acc;
                notifyAll();
                 
    
    ...
    ...

前面我们提到每个对象有一个内部锁(锁对象),其实将静态方法声明为synchronized也是可以的。当该方法被调用时,Bank.class对象的锁被锁定。此时,其他线程就不能调用同一个类的任何同步静态方法

volatile域

volatile关键字为实例域的同步访问提供了一种免锁机制。但是volatile不能提供原子性。

个人理解:volatile声明的变量,线程每次访问这些变量时,都会去存储空间取到它的实际值,而不是缓存的值。但对一个volatile声明的变量做操作,比如a=0;不能保证后续的a值为0,因为此时也许其他线程改变了它的值。

死锁

比如有两个线程,当两个线程因为不满足条件对象都进入了阻塞状态,因而无法互相唤醒,这就进入了死锁。

线程局部变量

首先,ThreadLocal 不是用来解决共享对象的多线程访问问题的,一般情况下,通过ThreadLocal.set() 到线程中的对象是该线程自己使用的对象,其他线程是不需要访问的,也访问不到的。各个线程中访问的是不同的对象。


另外,说ThreadLocal使得各线程能够保持各自独立的一个对象,并不是通过ThreadLocal.set()来实现的,而是通过每个线程中的new 对象 的操作来创建的对象,每个线程创建一个,不是什么对象的拷贝或副本。通过ThreadLocal.set()将这个新创建的对象的引用保存到各线程的自己的一个map中,每个线程都有这样一个map,执行ThreadLocal.get()时,各线程从自己的map中取出放进去的对象,因此取出来的是各自自己线程中的对象,ThreadLocal实例是作为map的key来使用的。

如果ThreadLocal.set()进去的东西本来就是多个线程共享的同一个对象,那么多个线程的ThreadLocal.get()取得的还是这个共享对象本身,还是有并发访问问题。

下面来看一个hibernate中典型的ThreadLocal的应用:

private static final ThreadLocal threadSession = new ThreadLocal();  
public static Session getSession() throws InfrastructureException   
    Session s = (Session) threadSession.get();  
    try   
        if (s == null)   
           s = getSessionFactory().openSession();  
           threadSession.set(s);  
             
        catch (HibernateException ex)   
          throw new InfrastructureException(ex);  
         
    return s;  
  

可以看到在getSession()方法中,首先判断当前线程中有没有放进去session,如果还没有,那么通过sessionFactory().openSession()来创建一个session,再将session set到线程中,实际是放到当前线程的ThreadLocalMap这个map中,这时,对于这个session的唯一引用就是当前线程中的那个ThreadLocalMap,而threadSession作为这个值的key,要取得这个session可以通过threadSession.get()来得到,里面执行的操作实际是先取得当前线程中的ThreadLocalMap,然后将threadSession作为key将对应的值取出。这个session相当于线程的私有变量,而不是public的。
显然,其他线程中是取不到这个session的,他们也只能取到自己的ThreadLocalMap中的东西。要是session是多个线程共享使用的,那还不乱套了。

总之,ThreadLocal不是用来解决对象共享访问问题的,而主要是提供了保持对象的方法和避免参数传递的方便的对象访问方式。归纳了两点:
1。每个线程中都有一个自己的ThreadLocalMap类对象,可以将线程自己的对象保持到其中,各管各的,线程可以正确的访问到自己的对象。
2。将一个共用的ThreadLocal静态实例作为key,将不同对象的引用保存到不同线程的ThreadLocalMap中,然后在线程执行的各处通过这个静态ThreadLocal实例的get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。

锁测试与超时

线程在调用lock方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。锁对象可以使用tryLock()方法试图申请一个锁,如果成功获得锁则返回true,否则返回false。

tryLock(100,TimeUnit.MILLISECONDS)可以设置一个超时时间。表示阻塞时间不会超过给定的值。

等待一个条件时也可以设置等待超时,await(100,TimeUnit.MILLISECONDS),如果一个线程被其他线程通过signalALL或signal激活,或超时时限已达到,或者线程被中断,await方法将返回。

 读/写锁

如果很多线程从一个数据结构读取数据而很少线程修改数据,使用ReentrantReadWriteLock类可以设置读写锁。

readLock():得到一个可以被多个读操作共用的读锁,但会排斥所有的写操作。

writeLock():得到一个写锁,排斥所有其他的读操作和写操作。

(1)构造ReentrantReadWriteLock对象

private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

(2)抽取读锁和写锁

private Lock readLock = rwl.readLock();

private Lock writeLock = rwl.writeLock();

(3)对所有的获取方法加读锁

public int getDate()

  readLock.lock();

  try

  finally readLock.unlock();

(4)对所有的修改方法加写锁

public int changeDate()

  writeLock.lock();

  try

  finally writeLock.unlock();

以上是关于(十八)多线程的主要内容,如果未能解决你的问题,请参考以下文章

(十八)多线程

(十八)多线程

Python学习笔记(二十八)多线程

Python爬虫(十八)_多线程糗事百科案例

Python爬虫(十八)_多线程糗事百科案例

IT十八掌作业_java基础第八天_多线程