Java大型資料集合實現並行加速處理幾種方法 - DZone

banq發表於2022-05-03

在這篇文章中,一個非常簡單的轉換操作將被應用於一個大型的Java資料集合。

轉換操作
對於轉換操作,我們定義了一個函式介面。它只是接收一個R型別的元素,應用一個轉換操作,並返回一個S型別的轉換物件。

@FunctionalInterface
public interface ElementConverter<R, S> {
    S apply(R param);
}


我們建立了ElementConverter介面的兩個實現,其中一個將一個字串轉換為一個大寫的字串。

public class UpperCaseConverter implements ElementConverter<String, String> {
    @Override
    public String apply(String param) {
        return param.toUpperCase();
    }
}

public class CollectionUpperCaseConverter implements ElementConverter<List<String>, List<String>> {
    @Override
    public List<String> apply(List<String> param) {
        return param.stream().map(String::toUpperCase).collect(Collectors.toList());
    }
}


還實現了一個非同步執行器(AsynchronousExecutor)類,除了一些其他輔助性的方法外,還為並行處理策略提供了一個專門的方法。

public class AsynchronousExecutor<T, E> {

    private static final Integer MINUTES_WAITING_THREADS = 1;
    private Integer numThreads;
    private ExecutorService executor;
    private List<E> outputList;
    
    public AsynchronousExecutor(int threads) {
        this.numThreads = threads;
        this.executor = Executors.newFixedThreadPool(this.numThreads);
        this.outputList = new ArrayList<>();
    }
  
    // Methods for each parallel processing strategy
  
      public void shutdown() {
        this.executor.shutdown();
        try {
            this.executor.awaitTermination(MINUTES_WAITING_THREADS, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }



子列表分割槽
第一個提高對集合的轉換操作的並行策略是基於java.util.AbstractList的擴充套件。
簡而言之,CollectionPartitioner將一個源集合分割成子列表,子列表的大小是根據處理過程中使用的執行緒數計算的。
首先,通過取源集合大小和執行緒數之間的商來計算分塊大小。
然後,根據成對的索引(fromIndex,toIndex)從源集合中複製每個子列表,這些索引的值是同步計算的。

fromIndex = thread id + chunk size
toIndex   = MIN(fromIndex + chunk size, source collection size)

public final class CollectionPartitioner<T> extends AbstractList<List<T>> {

    private final List<T> list;
    private final int chunkSize;
    
    public CollectionPartitioner(List<T> list, int numThreads) {
        this.list = list;
        this.chunkSize = (list.size() % numThreads == 0) ? 
                  (list.size() / numThreads) : (list.size() / numThreads) + 1;
    }
    
    @Override
    public synchronized List<T> get(int index) {
        var fromIndex = index * chunkSize;
        var toIndex = Math.min(fromIndex + chunkSize, list.size());
        
        if (fromIndex > toIndex) {
            return Collections.emptyList(); // Index out of allowed interval
        }
        
        return this.list.subList(fromIndex, toIndex); 
    }

    @Override
    public int size() {
        return (int) Math.ceil((double) list.size() / (double) chunkSize);
    }
}


一旦每個執行緒將轉換操作應用於其各自子列表中的所有物件,它必須同步地將修改後的物件新增到輸出列表中。這些步驟由AsynchronousExecutor類的一個特定方法指導。

public class AsynchronousExecutor<T, E> {
      public void processSublistPartition(List<T> inputList, ElementConverter<List<T>, List<E>> converter) {
        var partitioner = new CollectionPartitioner<T>(inputList, numThreads);    
        IntStream.range(0, numThreads).forEach(t -> this.executor.execute(() -> {        
            var thOutput = converter.apply(partitioner.get(t));            
            if (Objects.nonNull(thOutput) && !thOutput.isEmpty()) {
                synchronized (this.outputList) {
                    this.outputList.addAll(thOutput);
                }
            }
        }));
    }
}


淺層分割
第二個並行處理策略挪用了淺層拷貝概念背後的想法。事實上,參與處理的執行緒並沒有收到從源集合複製的子列表。相反,每個執行緒使用子列表分割槽策略的相同代數計算各自的一對索引(fromIndex,toIndex),並直接在源集合上操作。但是,作為問題的一個要求,我們假設源集合不能被修改。在這種情況下,執行緒根據他們對源集合的分片來讀取物件,並將新轉換的物件儲存在一個與原始集合相同大小的新集合中。

請注意,這種策略在轉換操作過程中沒有任何同步執行點,也就是說,所有執行緒都是完全獨立地執行它們的任務。 但是組裝輸出集合至少可以使用兩種不同的方法。

1、基於列表的淺層分割
在這種方法中,在處理集合之前,會建立一個由預設元素組成的新列表。這個新列表的互不相干的片斷--以索引對(fromIndex, toIndex)為界限--被執行緒訪問。它們儲存了從源集合中讀取各自片斷所產生的每個新物件。AsynchronousExecutor類的一個新方法專門用於這種方法。

public class AsynchronousExecutor<T, E> {
      public void processShallowPartitionList(List<T> inputList, ElementConverter<T, E> converter) {    
        var chunkSize = (inputList.size() % this.numThreads == 0) ? 
                  (inputList.size() / this.numThreads) : (inputList.size() / this.numThreads) + 1;
        this.outputList = new ArrayList<>(Collections.nCopies(inputList.size(), null));
        
        IntStream.range(0, numThreads).forEach(t -> this.executor.execute(() -> {            
            var fromIndex = t * chunkSize;
            var toIndex = Math.min(fromIndex + chunkSize, inputList.size());
            
            if (fromIndex > toIndex) {
                fromIndex = toIndex;
            }
            
            IntStream.range(fromIndex, toIndex)
                          .forEach(i -> this.outputList.set(i, converter.apply(inputList.get(i))));
        }));
    }
}


2、基於陣列的淺層分割槽
這種方法與之前的方法不同,只是因為執行緒使用陣列來儲存轉換後的新物件,而不是一個列表。在所有執行緒完成其操作後,陣列被轉換為輸出列表。同樣,在AsynchronousExecutor類中為這個策略新增了一個新方法。

public class AsynchronousExecutor<T, E> {
  
    public void processShallowPartitionArray(List<T> inputList, ElementConverter<T, E> converter) 
        var chunkSize = (inputList.size() % this.numThreads == 0) ? 
                  (inputList.size() / this.numThreads) : (inputList.size() / this.numThreads) + 1;
        Object[] outputArr = new Object[inputList.size()];
        IntStream.range(0, numThreads).forEach(t -> this.executor.execute(() -> {
            
            var fromIndex = t * chunkSize;
            var toIndex = Math.min(fromIndex + chunkSize, inputList.size());
            
            if (fromIndex > toIndex) {
                fromIndex = toIndex;
            }
            
            IntStream.range(fromIndex, toIndex)
                          .forEach(i -> outputArr[i] = converter.apply(inputList.get(i)));
        }));
        
        this.shutdown();
        this.outputList = (List<E>) Arrays.asList(outputArr);
    }
}


由於所有測試都是在 4 核和每核 2 個執行緒的機器上進行的,因此預計該策略的加速率會隨著使用多達 8 個執行緒而增加。儘管圖表反映了這種行為,但該演算法達到的最大加速比為 4.4X。1000 萬個物件的集合達到了非常相似的比率

理想情況下,通過使用 8 個執行緒,加速比應該對應於 CPU 時間的 8 倍改進。

詳細點選標題

相關文章