當(dāng)前位置:首頁 > 公眾號(hào)精選 > 架構(gòu)師社區(qū)
[導(dǎo)讀]一個(gè)詭異的線上問題:線上程序使用了 NIO FileChannel 的 堆內(nèi)內(nèi)存作為緩沖區(qū),讀寫文件,邏輯可以說相當(dāng)簡(jiǎn)單,但根據(jù)監(jiān)控卻發(fā)現(xiàn)堆外內(nèi)存飆升,導(dǎo)致了 OutOfMemeory。

引子

記得那是一個(gè)風(fēng)和日麗的周末,太陽紅彤彤,花兒五顏六色,96 年的普哥微信找到我,描述了一個(gè)詭異的線上問題:線上程序使用了 NIO FileChannel 的 堆內(nèi)內(nèi)存作為緩沖區(qū),讀寫文件,邏輯可以說相當(dāng)簡(jiǎn)單,但根據(jù)監(jiān)控卻發(fā)現(xiàn)堆外內(nèi)存飆升,導(dǎo)致了 OutOfMemeory 的異常。

由這個(gè)線上問題,引出了這篇文章的主題,主要包括:FileChannel 源碼分析,堆外內(nèi)存監(jiān)控,堆外內(nèi)存回收。

問題分析&源碼分析

根據(jù)異常日志的定位,發(fā)現(xiàn)的確使用的是 HeapByteBuffer 來進(jìn)行讀寫,但卻導(dǎo)致堆外內(nèi)存飆升,隨即翻了 FileChannel 的源碼,來一探究竟:

FileChannel 使用的是 IOUtil 來進(jìn)行讀寫(只分析讀的邏輯,寫的邏輯行為和讀其實(shí)一致,不進(jìn)行重復(fù)分析)

			
  1. //sun.nio.ch.IOUtil#read

  2. static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {

  3. if (var1.isReadOnly()) {

  4. throw new IllegalArgumentException("Read-only buffer");

  5. } else if (var1 instanceof DirectBuffer) {

  6. return readIntoNativeBuffer(var0, var1, var2, var4);

  7. } else {

  8. ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());

  9. int var7;

  10. try {

  11. int var6 = readIntoNativeBuffer(var0, var5, var2, var4);

  12. var5.flip();

  13. if (var6 > 0) {

  14. var1.put(var5);

  15. }

  16. var7 = var6;

  17. } finally {

  18. Util.offerFirstTemporaryDirectBuffer(var5);

  19. }

  20. return var7;

  21. }

  22. }

可以發(fā)現(xiàn)當(dāng)使用 HeapByteBuffer 時(shí),會(huì)走到下面這行比較奇怪的代碼分支:

			
  1. Util.getTemporaryDirectBuffer(var1.remaining());

這個(gè) Util 封裝了更為底層的一些 IO 邏輯

			
  1. package sun.nio.ch;

  2. public class Util {

  3. private static ThreadLocal<Util.BufferCache> bufferCache;


  4. public static ByteBuffer getTemporaryDirectBuffer(int var0) {

  5. if (isBufferTooLarge(var0)) {

  6. return ByteBuffer.allocateDirect(var0);

  7. } else {

  8. // FOUCS ON THIS LINE

  9. Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();

  10. ByteBuffer var2 = var1.get(var0);

  11. if (var2 != null) {

  12. return var2;

  13. } else {

  14. if (!var1.isEmpty()) {

  15. var2 = var1.removeFirst();

  16. free(var2);

  17. }


  18. return ByteBuffer.allocateDirect(var0);

  19. }

  20. }

  21. }

  22. }

isBufferTooLarge 這個(gè)方法會(huì)根據(jù)傳入 Buffer 的大小決定如何分配堆外內(nèi)存,如果過大,直接分布大緩沖區(qū);如果不是太大,會(huì)使用 bufferCache 這個(gè) ThreadLocal 變量來進(jìn)行緩存,從而復(fù)用(實(shí)際上這個(gè)數(shù)值非常大,幾乎不會(huì)走進(jìn)直接分配堆外內(nèi)存這個(gè)分支)。這么看來似乎發(fā)現(xiàn)了兩個(gè)不得了的結(jié)論:

  1. 使用 HeapByteBuffer 讀寫都會(huì)經(jīng)過 DirectByteBuffer,寫入數(shù)據(jù)的流轉(zhuǎn)方式其實(shí)是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,讀取數(shù)據(jù)的流轉(zhuǎn)方式正好相反。

  2. 大多數(shù)情況下,會(huì)申請(qǐng)一塊跟線程綁定的堆外緩存,這意味著,線程越多,這塊臨時(shí)的堆外緩存就越大。

看到這兒,似乎線上的問題有了一點(diǎn)眉目:很有可能是多線程使用堆內(nèi)內(nèi)存寫入文件,而額外分配這塊堆外緩存導(dǎo)致了內(nèi)存溢出。在驗(yàn)證這個(gè)猜測(cè)之前,我們最好能直觀地監(jiān)控到堆外內(nèi)存的使用量,這才能增加我們定位問題的信心。

實(shí)現(xiàn)堆外內(nèi)存的監(jiān)控

JDK 提供了一個(gè)非常好用的監(jiān)控工具 —— Java VisualVM。我們只需要為他安裝 2 個(gè)插件,即可很方便地實(shí)現(xiàn)堆外內(nèi)存的監(jiān)控。

進(jìn)入本地 JDK 的可執(zhí)行目錄(在我本地是:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin),找到 jvisualvm 命令,雙擊即可打開一個(gè)可視化的界面

一文探討堆外內(nèi)存的監(jiān)控與回收

左側(cè)樹狀目錄可以選擇需要監(jiān)控的 Java 進(jìn)程,右側(cè)是監(jiān)控的維度信息,除了 CPU、線程、堆、類等信息,還可以通過上方的【工具(T)】 安裝插件,增加 MBeans、Buffer Pools 等維度的監(jiān)控。

一文探討堆外內(nèi)存的監(jiān)控與回收

Buffer Pools 插件可以監(jiān)控堆外內(nèi)存(包含 DirectByteBuffer 和 MappedByteBuffer),如下圖所示:

一文探討堆外內(nèi)存的監(jiān)控與回收

左側(cè)對(duì)應(yīng) DirectByteBuffer,右側(cè)對(duì)應(yīng) MappedByteBuffer。

復(fù)現(xiàn)問題

為了復(fù)現(xiàn)線上的問題,我們使用一個(gè)程序,不斷開啟線程使用堆內(nèi)內(nèi)存作為緩沖區(qū)進(jìn)行文件的讀取操作,并監(jiān)控該進(jìn)程的堆外內(nèi)存使用情況。

			
  1. public class ReadByHeapByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. File data = new File("/tmp/data.txt");

  4. FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();

  5. ByteBuffer buffer = ByteBuffer.allocate(4 * 1024 * 1024);

  6. for (int i = 0; i < 1000; i++) {

  7. Thread.sleep(1000);

  8. new Thread(new Runnable() {

  9. @Override

  10. public void run() {

  11. try {

  12. fileChannel.read(buffer);

  13. buffer.clear();

  14. } catch (IOException e) {

  15. e.printStackTrace();

  16. }

  17. }

  18. }).start();

  19. }

  20. }

  21. }

運(yùn)行一段時(shí)間后,我們觀察下堆外內(nèi)存的使用情況

一文探討堆外內(nèi)存的監(jiān)控與回收

如上圖左所示,堆外內(nèi)存的確開始瘋漲了,符合我們的預(yù)期,堆外緩存和線程綁定,當(dāng)線程非常多時(shí),即使只使用了 4M 的堆內(nèi)內(nèi)存,也可能會(huì)造成極大的堆外內(nèi)存膨脹,在中間發(fā)生了一次斷崖,推測(cè)是線程執(zhí)行完畢 or GC,導(dǎo)致了內(nèi)存的釋放。

知曉了這一點(diǎn),相信大家今后使用堆內(nèi)內(nèi)存時(shí)可能就會(huì)更加注意了,我總結(jié)了兩個(gè)注意點(diǎn):

  1. 使用 HeapByteBuffer 還需要經(jīng)過一次 DirectByteBuffer 的拷貝,在追求極致性能的場(chǎng)景下是可以通過直接復(fù)用堆外內(nèi)存來避免的。

  2. 多線程下使用 HeapByteBuffer 進(jìn)行文件讀寫,要注意 ThreadLocal<Util.BufferCache>bufferCache導(dǎo)致的堆外內(nèi)存膨脹的問題。

問題深究

那大家有沒有想過,為什么 JDK 要如此設(shè)計(jì)?為什么不直接使用堆內(nèi)內(nèi)存寫入 PageCache 進(jìn)而落盤呢?為什么一定要經(jīng)過 DirectByteBuffer 的拷貝呢?

在知乎的相關(guān)問題中,R 大和曾澤堂 兩位同學(xué)進(jìn)行了解答,是我比較認(rèn)同的解釋:

作者:RednaxelaFX

鏈接:https://www.zhihu.com/question/57374068/answer/152691891

來源:知乎

這里其實(shí)是在遷就OpenJDK里的HotSpot VM的一點(diǎn)實(shí)現(xiàn)細(xì)節(jié)。

HotSpot VM 里的 GC 除了 CMS 之外都是要移動(dòng)對(duì)象的,是所謂“compacting GC”。

如果要把一個(gè)Java里的 byte[] 對(duì)象的引用傳給native代碼,讓native代碼直接訪問數(shù)組的內(nèi)容的話,就必須要保證native代碼在訪問的時(shí)候這個(gè) byte[] 對(duì)象不能被移動(dòng),也就是要被“pin”(釘)住。

可惜 HotSpot VM 出于一些取舍而決定不實(shí)現(xiàn)單個(gè)對(duì)象層面的 object pinning,要 pin 的話就得暫時(shí)禁用 GC——也就等于把整個(gè) Java 堆都給 pin 住。

所以 Oracle/Sun JDK / OpenJDK 的這個(gè)地方就用了點(diǎn)繞彎的做法。它假設(shè)把 HeapByteBuffer 背后的 byte[] 里的內(nèi)容拷貝一次是一個(gè)時(shí)間開銷可以接受的操作,同時(shí)假設(shè)真正的 I/O 可能是一個(gè)很慢的操作。

于是它就先把 HeapByteBuffer 背后的 byte[] 的內(nèi)容拷貝到一個(gè) DirectByteBuffer 背后的 native memory去,這個(gè)拷貝會(huì)涉及 sun.misc.Unsafe.copyMemory() 的調(diào)用,背后是類似 memcpy() 的實(shí)現(xiàn)。這個(gè)操作本質(zhì)上是會(huì)在整個(gè)拷貝過程中暫時(shí)不允許發(fā)生 GC 的。

然后數(shù)據(jù)被拷貝到 native memory 之后就好辦了,就去做真正的 I/O,把 DirectByteBuffer 背后的 native memory 地址傳給真正做 I/O 的函數(shù)。這邊就不需要再去訪問 Java 對(duì)象去讀寫要做 I/O 的數(shù)據(jù)了。

總結(jié)一下就是:

  • 為了方便 GC 的實(shí)現(xiàn),DirectByteBuffer 指向的 native memory 是不受 GC 管轄的

  • HeapByteBuffer 背后使用的是 byte 數(shù)組,其占用的內(nèi)存不一定是連續(xù)的,不太方便 JNI 方法的調(diào)用

  • 數(shù)組實(shí)現(xiàn)在不同 JVM 中可能會(huì)不同

堆外內(nèi)存的回收

繼續(xù)深究一下一個(gè)話題,也是我的微信交流群中曾經(jīng)有人提出過的一個(gè)疑問,到底該如何回收 DirectByteBuffer?既然可以監(jiān)控堆外內(nèi)存,那驗(yàn)證堆外內(nèi)存的回收就變得很容易實(shí)現(xiàn)了。

CASE 1:分配 1G 的 DirectByteBuffer,等待用戶輸入后,賦值為 null,之后阻塞持續(xù)觀察堆外內(nèi)存變化

			
  1. public class WriteByDirectByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

  4. System.in.read();

  5. buffer = null;

  6. new CountDownLatch(1).await();

  7. }

  8. }

一文探討堆外內(nèi)存的監(jiān)控與回收

結(jié)論:變量雖然置為了 null,但內(nèi)存依舊持續(xù)占用。

CASE 2:分配 1G DirectByteBuffer,等待用戶輸入后,賦值為 null,手動(dòng)觸發(fā) GC,之后阻塞持續(xù)觀察堆外內(nèi)存變化

			
  1. public class WriteByDirectByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

  4. System.in.read();

  5. buffer = null;

  6. System.gc();

  7. new CountDownLatch(1).await();

  8. }

  9. }

一文探討堆外內(nèi)存的監(jiān)控與回收

結(jié)論:GC 時(shí)會(huì)觸發(fā)堆外空閑內(nèi)存的回收。

CASE 3:分配 1G DirectByteBuffer,等待用戶輸入后,手動(dòng)回收堆外內(nèi)存,之后阻塞持續(xù)觀察堆外內(nèi)存變化

			
  1. public class WriteByDirectByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

  4. System.in.read();

  5. ((DirectBuffer) buffer).cleaner().clean();

  6. new CountDownLatch(1).await();

  7. }

  8. }

一文探討堆外內(nèi)存的監(jiān)控與回收

結(jié)論:手動(dòng)回收可以立刻釋放堆外內(nèi)存,不需要等待到 GC 的發(fā)生。

對(duì)于 MappedByteBuffer 這個(gè)有點(diǎn)神秘的類,它的回收機(jī)制大概和 DirectByteBuffer 類似,體現(xiàn)在右邊的 Mapped 之中,我們就不重復(fù) CASE1 和 CASE2 的測(cè)試了,直接給出結(jié)論,在 GC 發(fā)生或者操作系統(tǒng)主動(dòng)清理時(shí) MappedByteBuffer 會(huì)被回收。但也不是不進(jìn)行測(cè)試,我們會(huì)對(duì) MappedByteBuffer 進(jìn)行更有意思的研究。

CASE 4:手動(dòng)回收 MappedByteBuffer。

			
  1. public class MmapUtil {

  2. public static void clean(MappedByteBuffer mappedByteBuffer) {

  3. ByteBuffer buffer = mappedByteBuffer;

  4. if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)

  5. return;

  6. invoke(invoke(viewed(buffer), "cleaner"), "clean");

  7. }


  8. private static Object invoke(final Object target, final String methodName, final Class... args) {

  9. return AccessController.doPrivileged(new PrivilegedAction<Object>() {

  10. public Object run() {

  11. try {

  12. Method method = method(target, methodName, args);

  13. method.setAccessible(true);

  14. return method.invoke(target);

  15. } catch (Exception e) {

  16. throw new IllegalStateException(e);

  17. }

  18. }

  19. });

  20. }


  21. private static Method method(Object target, String methodName, Class[] args)

  22. throws NoSuchMethodException {

  23. try {

  24. return target.getClass().getMethod(methodName, args);

  25. } catch (NoSuchMethodException e) {

  26. return target.getClass().getDeclaredMethod(methodName, args);

  27. }

  28. }


  29. private static ByteBuffer viewed(ByteBuffer buffer) {

  30. String methodName = "viewedBuffer";

  31. Method[] methods = buffer.getClass().getMethods();

  32. for (int i = 0; i < methods.length; i++) {

  33. if (methods[i].getName().equals("attachment")) {

  34. methodName = "attachment";

  35. break;

  36. }

  37. }

  38. ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);

  39. if (viewedBuffer == null)

  40. return buffer;

  41. else

  42. return viewed(viewedBuffer);

  43. }

  44. }

這個(gè)類曾經(jīng)在我的《文件 IO 的一些最佳實(shí)踐》中有所介紹,在這里我們將驗(yàn)證它的作用。編寫測(cè)試類:

			
  1. public class WriteByMappedByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. File data = new File("/tmp/data.txt");

  4. data.createNewFile();

  5. FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();

  6. MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024);

  7. System.in.read();

  8. MmapUtil.clean(map);

  9. new CountDownLatch(1).await();

  10. }

  11. }

一文探討堆外內(nèi)存的監(jiān)控與回收

結(jié)論:通過一頓復(fù)雜的反射操作,成功地手動(dòng)回收了 Mmap 的內(nèi)存映射。

CASE 5:測(cè)試 Mmap 的內(nèi)存占用

			
  1. public class WriteByMappedByteBufferTest {

  2. public static void main(String[] args) throws IOException, InterruptedException {

  3. File data = new File("/tmp/data.txt");

  4. data.createNewFile();

  5. FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();

  6. for (int i = 0; i < 1000; i++) {

  7. fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024);

  8. }

  9. System.out.println("map finish");

  10. new CountDownLatch(1).await();

  11. }

  12. }

我嘗試映射了 1000G 的內(nèi)存,我的電腦顯然沒有 1000G 這么大內(nèi)存,那么監(jiān)控是如何反饋的呢?

一文探討堆外內(nèi)存的監(jiān)控與回收

幾乎在瞬間,控制臺(tái)打印出了 map finish 的日志,也意味著 1000G 的內(nèi)存映射幾乎是不耗費(fèi)時(shí)間的,為什么要做這個(gè)測(cè)試?就是為了解釋內(nèi)存映射并不等于內(nèi)存占用,很多文章認(rèn)為內(nèi)存映射這種方式可以大幅度提升文件的讀寫速度,并宣稱“寫 MappedByteBuffer 就等于寫內(nèi)存”,實(shí)際是非常錯(cuò)誤的認(rèn)知。通過控制面板可以查看到該 Java 進(jìn)程(pid 39040)實(shí)際占用的內(nèi)存,僅僅不到 100M。(關(guān)于 Mmap 的使用場(chǎng)景和方式可以參考我之前的文章)

一文探討堆外內(nèi)存的監(jiān)控與回收

結(jié)論:MappedByteBuffer 映射出一片文件內(nèi)容之后,不會(huì)全部加載到內(nèi)存中,而是會(huì)進(jìn)行一部分的預(yù)讀(體現(xiàn)在占用的那 100M 上),MappedByteBuffer 不是文件讀寫的銀彈,它仍然依賴于 PageCache 異步刷盤的機(jī)制。通過 Java VisualVM 可以監(jiān)控到 mmap 總映射的大小,但并不是實(shí)際占用的內(nèi)存量。

總結(jié)

本文借助一個(gè)線上問題,分析了使用堆內(nèi)內(nèi)存仍然會(huì)導(dǎo)致堆外內(nèi)存分析的現(xiàn)象以及背后 JDK 如此設(shè)計(jì)的原因,并借助安裝了插件之后的 Java VisualVM 工具進(jìn)行了堆外內(nèi)存的監(jiān)控,進(jìn)而討論了如何正確的回收堆外內(nèi)存,以及糾正了一個(gè)很多人對(duì)于 MappedByteBuffer 的錯(cuò)誤認(rèn)知。


免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問題,請(qǐng)聯(lián)系我們,謝謝!

本站聲明: 本文章由作者或相關(guān)機(jī)構(gòu)授權(quán)發(fā)布,目的在于傳遞更多信息,并不代表本站贊同其觀點(diǎn),本站亦不保證或承諾內(nèi)容真實(shí)性等。需要轉(zhuǎn)載請(qǐng)聯(lián)系該專欄作者,如若文章內(nèi)容侵犯您的權(quán)益,請(qǐng)及時(shí)聯(lián)系本站刪除。
換一批
延伸閱讀

9月2日消息,不造車的華為或?qū)⒋呱龈蟮莫?dú)角獸公司,隨著阿維塔和賽力斯的入局,華為引望愈發(fā)顯得引人矚目。

關(guān)鍵字: 阿維塔 塞力斯 華為

倫敦2024年8月29日 /美通社/ -- 英國(guó)汽車技術(shù)公司SODA.Auto推出其旗艦產(chǎn)品SODA V,這是全球首款涵蓋汽車工程師從創(chuàng)意到認(rèn)證的所有需求的工具,可用于創(chuàng)建軟件定義汽車。 SODA V工具的開發(fā)耗時(shí)1.5...

關(guān)鍵字: 汽車 人工智能 智能驅(qū)動(dòng) BSP

北京2024年8月28日 /美通社/ -- 越來越多用戶希望企業(yè)業(yè)務(wù)能7×24不間斷運(yùn)行,同時(shí)企業(yè)卻面臨越來越多業(yè)務(wù)中斷的風(fēng)險(xiǎn),如企業(yè)系統(tǒng)復(fù)雜性的增加,頻繁的功能更新和發(fā)布等。如何確保業(yè)務(wù)連續(xù)性,提升韌性,成...

關(guān)鍵字: 亞馬遜 解密 控制平面 BSP

8月30日消息,據(jù)媒體報(bào)道,騰訊和網(wǎng)易近期正在縮減他們對(duì)日本游戲市場(chǎng)的投資。

關(guān)鍵字: 騰訊 編碼器 CPU

8月28日消息,今天上午,2024中國(guó)國(guó)際大數(shù)據(jù)產(chǎn)業(yè)博覽會(huì)開幕式在貴陽舉行,華為董事、質(zhì)量流程IT總裁陶景文發(fā)表了演講。

關(guān)鍵字: 華為 12nm EDA 半導(dǎo)體

8月28日消息,在2024中國(guó)國(guó)際大數(shù)據(jù)產(chǎn)業(yè)博覽會(huì)上,華為常務(wù)董事、華為云CEO張平安發(fā)表演講稱,數(shù)字世界的話語權(quán)最終是由生態(tài)的繁榮決定的。

關(guān)鍵字: 華為 12nm 手機(jī) 衛(wèi)星通信

要點(diǎn): 有效應(yīng)對(duì)環(huán)境變化,經(jīng)營(yíng)業(yè)績(jī)穩(wěn)中有升 落實(shí)提質(zhì)增效舉措,毛利潤(rùn)率延續(xù)升勢(shì) 戰(zhàn)略布局成效顯著,戰(zhàn)新業(yè)務(wù)引領(lǐng)增長(zhǎng) 以科技創(chuàng)新為引領(lǐng),提升企業(yè)核心競(jìng)爭(zhēng)力 堅(jiān)持高質(zhì)量發(fā)展策略,塑強(qiáng)核心競(jìng)爭(zhēng)優(yōu)勢(shì)...

關(guān)鍵字: 通信 BSP 電信運(yùn)營(yíng)商 數(shù)字經(jīng)濟(jì)

北京2024年8月27日 /美通社/ -- 8月21日,由中央廣播電視總臺(tái)與中國(guó)電影電視技術(shù)學(xué)會(huì)聯(lián)合牽頭組建的NVI技術(shù)創(chuàng)新聯(lián)盟在BIRTV2024超高清全產(chǎn)業(yè)鏈發(fā)展研討會(huì)上宣布正式成立。 活動(dòng)現(xiàn)場(chǎng) NVI技術(shù)創(chuàng)新聯(lián)...

關(guān)鍵字: VI 傳輸協(xié)議 音頻 BSP

北京2024年8月27日 /美通社/ -- 在8月23日舉辦的2024年長(zhǎng)三角生態(tài)綠色一體化發(fā)展示范區(qū)聯(lián)合招商會(huì)上,軟通動(dòng)力信息技術(shù)(集團(tuán))股份有限公司(以下簡(jiǎn)稱"軟通動(dòng)力")與長(zhǎng)三角投資(上海)有限...

關(guān)鍵字: BSP 信息技術(shù)
關(guān)閉
關(guān)閉