java线程回调

324 阅读9分钟

java线程回调

第二章 java线程如何返回数据、轮询、回调

前言

从单线程程序转向多线程编程程序时,最难掌握的一点就是如何从线程返回信息。上一篇文章所介绍的run()方法与start()方法都不会返回任何值。那么这一篇文章我们来看看如何拿到线程中的信息。


一、尝试从线程返回信息

这一小节将介绍一些能够简单想到获得线程中获取信息的方式,但不一定都奏效。以下将以代码进行说明。

1.使用类的私有字段获取线程中返回值

继承Thread 编写一个ReturnDigest 类,其中使用digest存放线程执行的结果。

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class ReturnDigest extends Thread{
    private String filename;
    private byte[] digest;

    public ReturnDigest(String filename){
        this.filename = filename;
    }

    @Override
    public void run(){
        try{
            FileInputStream in = new FileInputStream(filename);
            MessageDigest sha = MessageDigest.getInstance("SHA-256");
            DigestInputStream din = new DigestInputStream(in,sha);
            while (din.read() != -1);
            din.close();
            digest = sha.digest();
        }catch (IOException ex){
            System.err.println(ex);
        }catch (NoSuchAlgorithmException ex){
            System.err.println(ex);
        }
    }
    
    public byte[] getDigest(){
        return digest;
    }
}

编写一个ReturnDigestUserInterface 类,去执行两个线程看看结果如何。

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;

public class ReturnDigestUserInterface {
    public static void main(String[] args) {
        String[] s = new String[2];
        s[0] = "output.txt";
        s[1] = "data.txt";
        for (int i = 0; i < 2; i++) {
            ReturnDigest dr = new ReturnDigest(s[i]);
            dr.start();
            StringBuilder result = new StringBuilder(s[i]);
            result.append(": ");
            byte[] digest = dr.getDigest();
            result.append(DatatypeConverter.printHexBinary(digest));
            System.out.println(result);
        }
    }
}

执行结果如下

Exception in thread "main" java.lang.NullPointerException
	at javax.xml.bind.DatatypeConverterImpl.printHexBinary(DatatypeConverterImpl.java:475)
	at javax.xml.bind.DatatypeConverter.printHexBinary(DatatypeConverter.java:626)
	at com.example.gobuy.utils.ReturnDigestUserInterface.main(ReturnDigestUserInterface.java:16)

如果通过私有字段digest进行存放时,主程序并没有等待语句,而会启动线程后继续向下运行,那么执行到dr.getDigest(),由于线程是异步执行的,很可能在 getDigest() 被调用时,线程尚未完成digest的初始化或文件的读取和摘要的计算。而如果没有结束则会返回null,第一次访问digest则会返回空指针异常

2.竞态条件

将dr.getDigest()调用放在更后面。

代码演示

public class ReturnDigestUserInterface {
    
    
    public static void main(String[] args) {
        String[] s = new String[2];
        s[0] = "output.txt";
        s[1] = "data.txt";
        ReturnDigest[] dr = new ReturnDigest[2];
        for (int i = 0; i < 2; i++) {
            dr[i] = new ReturnDigest(s[i]);
            dr[i].start();
        }

        for (int i = 0; i < 2; i++) {
            StringBuilder result = new StringBuilder(s[i]);
            result.append(": ");
            byte[] digest = dr[i].getDigest();
            result.append(DatatypeConverter.printHexBinary(digest));
            System.out.println(result);
        }
    }
}

上述代码是将所有线程都开始后才往下进行线程结果获取,可是有经验的开发者就能看出,这段代码是不可行的,存粹以“运气”进行运行。如果上述线程启动后执行很快,那么有可能在所有线程都启动后,下面的getDigest()能够拿到值,但也可能某个线程执行较慢,那么又与之前的问题一样了。 那么结果到底是会正常还是异常,这由很多因素造成,包括产生多少线程、系统CPU和磁盘速度,系统有多少个CPU,以及java虚拟机为不同线程分配时间所用的算法等等。那么这些又称为竞态条件(race condition)。而上述很多条件我们无法控制,所以需要一个更好的办法去获得线程中的结果。

3.轮询

使用轮询,大多数人采用的解决方案是让获取方法返回一个标志值或抛出一个异常,直到设置了结果。然后主线程定期询问获取方法,查看是否返回了标志之外的值。

代码如下:

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;

public class PollingDigest {
    public static void main(String[] args) {
        String[] s = new String[2];
        s[0] = "output.txt";
        s[1] = "data.txt";
        ReturnDigest[] dr = new ReturnDigest[2];
        for (int i = 0; i < 2; i++) {
            dr[i] = new ReturnDigest(s[i]);
            dr[i].start();
        }

        for (int i = 0; i < 2; i++) {
            while (true){
                byte[] digest = dr[i].getDigest();
                if(digest!= null) {
                    StringBuilder result = new StringBuilder(s[i]);
                    result.append(": ");
                    result.append(DatatypeConverter.printHexBinary(digest));
                    System.out.println(result);
                    break;
                }
            }
        }
    }
}

结果如下

output.txt: D1966A1EE8B32860C9F441DE73947F7798CE0EB3225B3CC76A4654433458235A
data.txt: 12981A5D5D602DCFC34E1DAC029D940F42019B5D8D0D8117042EEEB3E1F241BB

由结果可见,这个方法是可行的,他会按照读入文件顺序给出结果,而不考虑每个线程的运行速度。即使第二个data.txt文件很小很快就完成,但他还是得等待第一个output.txt的结果返回。而且while循环做了很多无用功。

而在某些虚拟机上,主线程while会占用所有的可用时间,这样就没有给具体的计算数据的工作线程流出任何时间。显然不是一个好方法。

二、回调

回调是一个简单且有效的解决该问题的方法。这个方法的技巧把主线程中不断询问,改为让子线程告诉主线程他是否结束。就是不要让老师不停的来问你作业做完没有,而是自己做完主动向老师报备。这是通过调用主类(启动线程的类)中的一个方法来实现。这就被称为回调(callback)。这样主线程就开启子线程后,即可休息,不需要不断进行轮询,从而占用子线程运行资源。

代码如下: 实现Runable子线程类

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class CallbackDigest implements Runnable{
    private String filename;
    public CallbackDigest(String filename){
        this.filename=filename;
    }
    @Override
    public void run(){
        try{
            FileInputStream in = new FileInputStream(filename);
            MessageDigest sha = MessageDigest.getInstance("SHA-256");
            DigestInputStream din = new DigestInputStream(in,sha);
            while (din.read() != -1);
            din.close();
            byte[] digest = sha.digest();
            CallbackDigestUserInterface.receiveDigest(digest,filename);

        }catch (IOException ex){
            System.err.println(ex);
        }catch (NoSuchAlgorithmException ex){
            System.err.println(ex);
        }
    }
}

主线程及回调函数

package com.example.gobuy.utils;
import javax.xml.bind.DatatypeConverter;

public class CallbackDigestUserInterface {

    public static void receiveDigest(byte[] digest, String name){
        StringBuilder result = new StringBuilder(name);
        result.append(": ");
        result.append(DatatypeConverter.printHexBinary(digest));
        System.out.println(result);
    }
    public static void main(String[] args) {
        String[] s = new String[2];
        s[0] = "output.txt";
        s[1] = "data.txt";

        for (int i = 0; i < 2; i++) {
            CallbackDigest cd = new CallbackDigest(s[i]);
            Thread t = new Thread(cd);
            t.start();
        }
    }
}

data.txt: 12981A5D5D602DCFC34E1DAC029D940F42019B5D8D0D8117042EEEB3E1F241BB
output.txt: D1966A1EE8B32860C9F441DE73947F7798CE0EB3225B3CC76A4654433458235A

从结果可以看出,文件大小较小的data.txt率先打印出来(即使计算他的线程是后执行的)。在子线程代码中他执行了主程序中提供了一个静态方法进行回调,这样子线程类就只需要主程序中的回调函数方法名。而且这次主线程main函数中不进行数据读取与显示,只用来创建线程,其他方法都放进了回调函数中。

下面不用静态方法,而是用回调实例方法进行演示。

子线程类InstanceCallbackDigest

package com.example.gobuy.utils;

import java.io.FileInputStream;
import java.io.IOException;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class InstanceCallbackDigest implements Runnable{
    private String filename;
    private InstanceCallbackDigestUserInterface callback;
    public InstanceCallbackDigest(String filename, InstanceCallbackDigestUserInterface callback){
        this.callback=callback;
        this.filename=filename;
    }
    @Override
    public void run(){
        try{
            FileInputStream in = new FileInputStream(filename);
            MessageDigest sha = MessageDigest.getInstance("SHA-256");
            DigestInputStream din = new DigestInputStream(in,sha);
            while (din.read() != -1);
            din.close();
            byte[] digest = sha.digest();
            callback.receiveDigest(digest);

        }catch (IOException ex){
            System.err.println(ex);
        }catch (NoSuchAlgorithmException ex){
            System.err.println(ex);
        }
    }
}

主线程类InstanceCallbackDigestUserInterface

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;

public class InstanceCallbackDigestUserInterface {
    private String filename;
    private byte[] digest;
    public InstanceCallbackDigestUserInterface(String filename){
        this.filename=filename;
    }
    public void calculateDigest(){
        InstanceCallbackDigest cb = new InstanceCallbackDigest(filename,this);
        Thread t = new Thread(cb);
        t.start();
    }
    void receiveDigest(byte[] digest){
        this.digest=digest;
        System.out.println(this);
    }
    @Override
    public String toString(){
        String result = filename + ": ";
        if (digest !=null){
            result += DatatypeConverter.printHexBinary(digest);
        }else {
            result += "digest not avaliable";
        }
        return result;
    }

    public static void main(String[] args) {
        String[] s = new String[2];
        s[0] = "output.txt";
        s[1] = "data.txt";

        for (int i = 0; i < 2; i++) {
            InstanceCallbackDigestUserInterface d = new InstanceCallbackDigestUserInterface(s[i]);
            d.calculateDigest();
        }
    }
}

子线程中拥有主线程类的一个对象,从而使用对象的方法进行回调。 主线程中使用实例方法替代静态方法进行回调虽然麻烦一些,但有很多优点。 首先,主类的各个实例只映射一个文件,可以跟踪记录这个文件信息,而不需要额外的数据结构。 其二,这个实例在必要时可以重新计算某个文件。 其三,这种机制更灵活,在实例方法中,我们还可以扩展其他功能。

代码中新增的calculateDigest()方法是进行子线程启动的,有的同学可能会将方法写入构造函数中,但在构造函数中启动线程是个危险的操作,特别是该子线程还需要回调自身主类对象的方法。有可能在构造函数结束前,子线程进行了回调,这样可能就会出现问题。所以还是将子线程启动放入其他函数中进行。

回调函数的其他妙用:例如:如果有多个对象需要获得子线程的计算结果,那么子线程可以保存一个要回调的对象列表,这样自己计算完成,再依次调用对象列表进行回调,就可以将结果通知给多个对象。那么这些特定的对象可以通过调用Thread或Runnable类的一个方法把自己添加到这个列表中完成注册。如果有多个类的实例需要计算结果,则可以定义一个接口,所有类都需要实现这个新接口。这个接口将声明回调方法。这也就是观察者-设计模式

代码示例:回调接口

public interface CallbackReceiver {
    void onResultReady(int result);
}

创建实现回调接口的类

public class ClassA implements CallbackReceiver {
    @Override
    public void onResultReady(int result) {
        System.out.println("ClassA received result: " + result);
    }
}
 
public class ClassB implements CallbackReceiver {
    @Override
    public void onResultReady(int result) {
        System.out.println("ClassB received result: " + result);
    }
}

创建线程类并管理回调

import java.util.ArrayList;
import java.util.List;
 
public class ResultCalculatorThread extends Thread {
    private List<CallbackReceiver> callbackReceivers = new ArrayList<>();
 
    // 方法允许其他对象注册回调
    public void registerCallbackReceiver(CallbackReceiver receiver) {
        callbackReceivers.add(receiver);
    }
 
    @Override
    public void run() {
        // 模拟计算过程
        int result = calculateResult();
 
        // 通知所有注册的对象
        for (CallbackReceiver receiver : callbackReceivers) {
            receiver.onResultReady(result);
        }
    }
 
    // 模拟计算过程的方法
    private int calculateResult() {
        // 这里可以放置实际计算逻辑,例如:
        try {
            Thread.sleep(2000); // 模拟耗时计算
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return 42; // 假设计算结果为42
    }
 
    public static void main(String[] args) {
        ResultCalculatorThread calculatorThread = new ResultCalculatorThread();
 
        ClassA classA = new ClassA();
        ClassB classB = new ClassB();
 
        // 注册回调
        calculatorThread.registerCallbackReceiver(classA);
        calculatorThread.registerCallbackReceiver(classB);
 
        // 启动线程
        calculatorThread.start();
    }
}

总结

本章节介绍了java程序如何从子线程中获取返回值或数据,介绍了几种不可行的方法给大家提供编码思路,并提供了轮询和回调两种可行的程序设计思路进行子线程信息的获取。相比轮询机制,回调机制不用浪费过多CPU资源,并且更加灵活,可以处理更多的线程、和处理复杂情况。最后介绍了以基于回调方法设计的观察者设计模式。