<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
現有一個 10G 檔案的資料,裡面包含了 18-70 之間的整數,分別表示 18-70 歲的人群數量統計。假設年齡範圍分佈均勻,分別表示系統中所有使用者的年齡數,找出重複次數最多的那個數,現有一臺記憶體為 4G、2 核 CPU 的電腦,請寫一個演演算法實現。
23,31,42,19,60,30,36,........
Java 中一個整數佔 4 個位元組,模擬 10G 為 30 億左右個資料, 採用追加模式寫入 10G 資料到硬碟裡。
每 100 萬個記錄寫一行,大概 4M 一行,10G 大概 2500 行資料。
package bigdata; import java.io.*; import java.util.Random; /** * @Desc: * @Author: bingbing * @Date: 2022/5/4 0004 19:05 */ public class GenerateData { private static Random random = new Random(); public static int generateRandomData(int start, int end) { return random.nextInt(end - start + 1) + start; } /** * 產生10G的 1-1000的資料在D槽 */ public void generateData() throws IOException { File file = new File("D:\ User.dat"); if (!file.exists()) { try { file.createNewFile(); } catch (IOException e) { e.printStackTrace(); } } int start = 18; int end = 70; long startTime = System.currentTimeMillis(); BufferedWriter bos = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true))); for (long i = 1; i < Integer.MAX_VALUE * 1.7; i++) { String data = generateRandomData(start, end) + ","; bos.write(data); // 每100萬條記錄成一行,100萬條資料大概4M if (i % 1000000 == 0) { bos.write("n"); } } System.out.println("寫入完成! 共花費時間:" + (System.currentTimeMillis() - startTime) / 1000 + " s"); bos.close(); } public static void main(String[] args) { GenerateData generateData = new GenerateData(); try { generateData.generateData(); } catch (IOException e) { e.printStackTrace(); } } }
上述程式碼調整引數執行 2 次,湊 10G 資料在 D 盤 User.dat 檔案裡:
準備好 10G 資料後,接著寫如何處理這些資料。
10G 的資料比當前擁有的執行記憶體大的多,不能全量載入到記憶體中讀取。如果採用全量載入,那麼記憶體會直接爆掉,只能按行讀取。Java 中的 bufferedReader 的 readLine() 按行讀取檔案裡的內容。
首先,我們寫一個方法單執行緒讀完這 30 億資料需要多少時間,每讀 100 行列印一次:
private static void readData() throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(FILE_NAME), "utf-8")); String line; long start = System.currentTimeMillis(); int count = 1; while ((line = br.readLine()) != null) { // 按行讀取 // SplitData.splitLine(line); if (count % 100 == 0) { System.out.println("讀取100行,總耗時間: " + (System.currentTimeMillis() - start) / 1000 + " s"); System.gc(); } count++; } running = false; br.close(); }
按行讀完 10G 的資料大概 20 秒,基本每 100 行,1 億多資料花 1 秒,速度還挺快。
通過單執行緒處理,初始化一個 countMap,key 為年齡,value 為出現的次數。將每行讀取到的資料按照 "," 進行分割,然後獲取到的每一項進行儲存到 countMap 裡。如果存在,那麼值 key 的 value+1。
for (int i = start; i <= end; i++) { try { File subFile = new File(dir + "\" + i + ".dat"); if (!file.exists()) { subFile.createNewFile(); } countMap.computeIfAbsent(i + "", integer -> new AtomicInteger(0)); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } }
單執行緒讀取並統計 countMap:
public static void splitLine(String lineData) { String[] arr = lineData.split(","); for (String str : arr) { if (StringUtils.isEmpty(str)) { continue; } countMap.computeIfAbsent(str, s -> new AtomicInteger(0)).getAndIncrement(); } }
通過比較找出年齡數最多的年齡並列印出來:
private static void findMostAge() { Integer targetValue = 0; String targetKey = null; Iterator<Map.Entry<String, AtomicInteger>> entrySetIterator = countMap.entrySet().iterator(); while (entrySetIterator.hasNext()) { Map.Entry<String, AtomicInteger> entry = entrySetIterator.next(); Integer value = entry.getValue().get(); String key = entry.getKey(); if (value > targetValue) { targetValue = value; targetKey = key; } } System.out.println("數量最多的年齡為:" + targetKey + "數量為:" + targetValue); }
package bigdata; import org.apache.commons.lang3.StringUtils; import java.io.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; /** * @Desc: * @Author: bingbing * @Date: 2022/5/4 0004 19:19 * 單執行緒處理 */ public class HandleMaxRepeatProblem_v0 { public static final int start = 18; public static final int end = 70; public static final String dir = "D:\dataDir"; public static final String FILE_NAME = "D:\ User.dat"; /** * 統計數量 */ private static Map<String, AtomicInteger> countMap = new ConcurrentHashMap<>(); /** * 開啟消費的標誌 */ private static volatile boolean startConsumer = false; /** * 消費者執行保證 */ private static volatile boolean consumerRunning = true; /** * 按照 "," 分割資料,並寫入到countMap裡 */ static class SplitData { public static void splitLine(String lineData) { String[] arr = lineData.split(","); for (String str : arr) { if (StringUtils.isEmpty(str)) { continue; } countMap.computeIfAbsent(str, s -> new AtomicInteger(0)).getAndIncrement(); } } } /** * init map */ static { File file = new File(dir); if (!file.exists()) { file.mkdir(); } for (int i = start; i <= end; i++) { try { File subFile = new File(dir + "\" + i + ".dat"); if (!file.exists()) { subFile.createNewFile(); } countMap.computeIfAbsent(i + "", integer -> new AtomicInteger(0)); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) { new Thread(() -> { try { readData(); } catch (IOException e) { e.printStackTrace(); } }).start(); } private static void readData() throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(FILE_NAME), "utf-8")); String line; long start = System.currentTimeMillis(); int count = 1; while ((line = br.readLine()) != null) { // 按行讀取,並向map裡寫入資料 SplitData.splitLine(line); if (count % 100 == 0) { System.out.println("讀取100行,總耗時間: " + (System.currentTimeMillis() - start) / 1000 + " s"); try { Thread.sleep(1000L); } catch (InterruptedException e) { e.printStackTrace(); } } count++; } findMostAge(); br.close(); } private static void findMostAge() { Integer targetValue = 0; String targetKey = null; Iterator<Map.Entry<String, AtomicInteger>> entrySetIterator = countMap.entrySet().iterator(); while (entrySetIterator.hasNext()) { Map.Entry<String, AtomicInteger> entry = entrySetIterator.next(); Integer value = entry.getValue().get(); String key = entry.getKey(); if (value > targetValue) { targetValue = value; targetKey = key; } } System.out.println("數量最多的年齡為:" + targetKey + "數量為:" + targetValue); } private static void clearTask() { // 清理,同時找出出現字元最大的數 findMostAge(); System.exit(-1); } }
總共花了 3 分鐘讀取完並統計完所有資料。
記憶體消耗為 2G-2.5G,CPU 利用率太低,只向上浮動了 20%-25% 之間。
要想提高 CPU 利用率,那麼可以使用多執行緒去處理。
下面我們使用多執行緒去解決這個 CPU 利用率低的問題。
使用多執行緒去消費讀取到的資料。 採用生產者、消費者模式去消費資料。
因為在讀取的時候是 比較快的,單執行緒的資料處理能力比較差。因此思路一的效能阻塞在取資料的一方且又是同步操作,導致整個鏈路的效能會變的很差。
所謂分治法就是分而治之,也就是說將海量資料分割處理。 根據 CPU 的能力初始化 n 個執行緒,每一個執行緒去消費一個佇列,這樣執行緒在消費的時候不會出現搶佔佇列的問題。同時為了保證執行緒安全和生產者消費者模式的完整,採用阻塞佇列。Java 中提供了 LinkedBlockingQueue 就是一個阻塞佇列。
使用 LinkedList 建立一個阻塞佇列列表:
private static List<LinkedBlockingQueue<String>> blockQueueLists = new LinkedList<>();
在 static 塊裡初始化阻塞佇列的數量和單個阻塞佇列的容量為 256。
上面講到了 30 億資料大概 2500 行,按行塞到佇列裡。20 個佇列,那麼每個佇列 125 個,因此可以容量可以設計為 256 即可。
//每個佇列容量為256 for (int i = 0; i < threadNums; i++) { blockQueueLists.add(new LinkedBlockingQueue<>(256)); }
為了實現負載的功能,首先定義一個 count 計數器,用來記錄行數:
private static AtomicLong count = new AtomicLong(0);
按照行數來計算佇列的下標 long index=count.get()%threadNums 。
下面演演算法就實現了對佇列列表中的佇列進行輪詢的投放:
static class SplitData { public static void splitLine(String lineData) { // System.out.println(lineData.length()); String[] arr = lineData.split("n"); for (String str : arr) { if (StringUtils.isEmpty(str)) { continue; } long index = count.get() % threadNums; try { // 如果滿了就阻塞 blockQueueLists.get((int) index).put(str); } catch (InterruptedException e) { e.printStackTrace(); } count.getAndIncrement(); } }
消費方在啟動執行緒的時候根據 index 去獲取到指定的佇列,這樣就實現了佇列的執行緒私有化。
private static void startConsumer() throws FileNotFoundException, UnsupportedEncodingException { //如果共用一個佇列,那麼執行緒不宜過多,容易出現搶佔現象 System.out.println("開始消費..."); for (int i = 0; i < threadNums; i++) { final int index = i; // 每一個執行緒負責一個queue,這樣不會出現執行緒搶佔佇列的情況。 new Thread(() -> { while (consumerRunning) { startConsumer = true; try { String str = blockQueueLists.get(index).take(); countNum(str); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } }
由於從佇列中多到的字串非常的龐大,如果又是用單執行緒呼叫 split(",") 去分割,那麼效能同樣會阻塞在這個地方。
// 按照arr的大小,運用多執行緒分割字串 private static void countNum(String str) { int[] arr = new int[2]; arr[1] = str.length() / 3; // System.out.println("分割的字串為start位置為:" + arr[0] + ",end位置為:" + arr[1]); for (int i = 0; i < 3; i++) { final String innerStr = SplitData.splitStr(str, arr); // System.out.println("分割的字串為start位置為:" + arr[0] + ",end位置為:" + arr[1]); new Thread(() -> { String[] strArray = innerStr.split(","); for (String s : strArray) { countMap.computeIfAbsent(s, s1 -> new AtomicInteger(0)).getAndIncrement(); } }).start(); } }
分割時從 0 開始,按照等分的原則,將字串 n 等份,每一個執行緒分到一份。
用一個 arr 陣列的 arr[0] 記錄每次的分割開始位置。arr[1] 記錄每次分割的結束位置,如果遇到的開始的字元不為 "," 那麼就 startIndex-1。如果結束的位置不為 "," 那麼將 endIndex 向後移一位。
如果 endIndex 超過了字串的最大長度,那麼就把最後一個字元賦值給 arr[1]。
/** * 按照 x座標 來分割 字串,如果切到的字元不為「,」, 那麼把座標向前或者向後移動一位。 * * @param line * @param arr 存放x1,x2座標 * @return */ public static String splitStr(String line, int[] arr) { int startIndex = arr[0]; int endIndex = arr[1]; char start = line.charAt(startIndex); char end = line.charAt(endIndex); if ((startIndex == 0 || start == ',') && end == ',') { arr[0] = endIndex + 1; arr[1] = arr[0] + line.length() / 3; if (arr[1] >= line.length()) { arr[1] = line.length() - 1; } return line.substring(startIndex, endIndex); } if (startIndex != 0 && start != ',') { startIndex = startIndex - 1; } if (end != ',') { endIndex = endIndex + 1; } arr[0] = startIndex; arr[1] = endIndex; if (arr[1] >= line.length()) { arr[1] = line.length() - 1; } return splitStr(line, arr); }
package bigdata; import cn.hutool.core.collection.CollectionUtil; import org.apache.commons.lang3.StringUtils; import java.io.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; /** * @Desc: * @Author: bingbing * @Date: 2022/5/4 0004 19:19 * 多執行緒處理 */ public class HandleMaxRepeatProblem { public static final int start = 18; public static final int end = 70; public static final String dir = "D:\dataDir"; public static final String FILE_NAME = "D:\ User.dat"; private static final int threadNums = 20; /** * key 為年齡, value為所有的行列表,使用佇列 */ private static Map<Integer, Vector<String>> valueMap = new ConcurrentHashMap<>(); /** * 存放資料的佇列 */ private static List<LinkedBlockingQueue<String>> blockQueueLists = new LinkedList<>(); /** * 統計數量 */ private static Map<String, AtomicInteger> countMap = new ConcurrentHashMap<>(); private static Map<Integer, ReentrantLock> lockMap = new ConcurrentHashMap<>(); // 佇列負載均衡 private static AtomicLong count = new AtomicLong(0); /** * 開啟消費的標誌 */ private static volatile boolean startConsumer = false; /** * 消費者執行保證 */ private static volatile boolean consumerRunning = true; /** * 按照 "," 分割資料,並寫入到檔案裡 */ static class SplitData { public static void splitLine(String lineData) { // System.out.println(lineData.length()); String[] arr = lineData.split("n"); for (String str : arr) { if (StringUtils.isEmpty(str)) { continue; } long index = count.get() % threadNums; try { // 如果滿了就阻塞 blockQueueLists.get((int) index).put(str); } catch (InterruptedException e) { e.printStackTrace(); } count.getAndIncrement(); } } /** * 按照 x座標 來分割 字串,如果切到的字元不為「,」, 那麼把座標向前或者向後移動一位。 * * @param line * @param arr 存放x1,x2座標 * @return */ public static String splitStr(String line, int[] arr) { int startIndex = arr[0]; int endIndex = arr[1]; char start = line.charAt(startIndex); char end = line.charAt(endIndex); if ((startIndex == 0 || start == ',') && end == ',') { arr[0] = endIndex + 1; arr[1] = arr[0] + line.length() / 3; if (arr[1] >= line.length()) { arr[1] = line.length() - 1; } return line.substring(startIndex, endIndex); } if (startIndex != 0 && start != ',') { startIndex = startIndex - 1; } if (end != ',') { endIndex = endIndex + 1; } arr[0] = startIndex; arr[1] = endIndex; if (arr[1] >= line.length()) { arr[1] = line.length() - 1; } return splitStr(line, arr); } public static void splitLine0(String lineData) { String[] arr = lineData.split(","); for (String str : arr) { if (StringUtils.isEmpty(str)) { continue; } int keyIndex = Integer.parseInt(str); ReentrantLock lock = lockMap.computeIfAbsent(keyIndex, lockMap -> new ReentrantLock()); lock.lock(); try { valueMap.get(keyIndex).add(str); } finally { lock.unlock(); } // boolean wait = true; // for (; ; ) { // if (!lockMap.get(Integer.parseInt(str)).isLocked()) { // wait = false; // valueMap.computeIfAbsent(Integer.parseInt(str), integer -> new Vector<>()).add(str); // } // // 當前阻塞,直到釋放鎖 // if (!wait) { // break; // } // } } } } /** * init map */ static { File file = new File(dir); if (!file.exists()) { file.mkdir(); } //每個佇列容量為256 for (int i = 0; i < threadNums; i++) { blockQueueLists.add(new LinkedBlockingQueue<>(256)); } for (int i = start; i <= end; i++) { try { File subFile = new File(dir + "\" + i + ".dat"); if (!file.exists()) { subFile.createNewFile(); } countMap.computeIfAbsent(i + "", integer -> new AtomicInteger(0)); // lockMap.computeIfAbsent(i, lock -> new ReentrantLock()); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) { new Thread(() -> { try { // 讀取資料 readData(); } catch (IOException e) { e.printStackTrace(); } }).start(); new Thread(() -> { try { // 開始消費 startConsumer(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } }).start(); new Thread(() -> { // 監控 monitor(); }).start(); } /** * 每隔60s去檢查棧是否為空 */ private static void monitor() { AtomicInteger emptyNum = new AtomicInteger(0); while (consumerRunning) { try { Thread.sleep(10 * 1000); } catch (InterruptedException e) { e.printStackTrace(); } if (startConsumer) { // 如果所有棧的大小都為0,那麼終止程序 AtomicInteger emptyCount = new AtomicInteger(0); for (int i = 0; i < threadNums; i++) { if (blockQueueLists.get(i).size() == 0) { emptyCount.getAndIncrement(); } } if (emptyCount.get() == threadNums) { emptyNum.getAndIncrement(); // 如果連續檢查指定次數都為空,那麼就停止消費 if (emptyNum.get() > 12) { consumerRunning = false; System.out.println("消費結束..."); try { clearTask(); } catch (Exception e) { System.out.println(e.getCause()); } finally { System.exit(-1); } } } } } } private static void readData() throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(FILE_NAME), "utf-8")); String line; long start = System.currentTimeMillis(); int count = 1; while ((line = br.readLine()) != null) { // 按行讀取,並向佇列寫入資料 SplitData.splitLine(line); if (count % 100 == 0) { System.out.println("讀取100行,總耗時間: " + (System.currentTimeMillis() - start) / 1000 + " s"); try { Thread.sleep(1000L); System.gc(); } catch (InterruptedException e) { e.printStackTrace(); } } count++; } br.close(); } private static void clearTask() { // 清理,同時找出出現字元最大的數 Integer targetValue = 0; String targetKey = null; Iterator<Map.Entry<String, AtomicInteger>> entrySetIterator = countMap.entrySet().iterator(); while (entrySetIterator.hasNext()) { Map.Entry<String, AtomicInteger> entry = entrySetIterator.next(); Integer value = entry.getValue().get(); String key = entry.getKey(); if (value > targetValue) { targetValue = value; targetKey = key; } } System.out.println("數量最多的年齡為:" + targetKey + "數量為:" + targetValue); System.exit(-1); } /** * 使用linkedBlockQueue * * @throws FileNotFoundException * @throws UnsupportedEncodingException */ private static void startConsumer() throws FileNotFoundException, UnsupportedEncodingException { //如果共用一個佇列,那麼執行緒不宜過多,容易出現搶佔現象 System.out.println("開始消費..."); for (int i = 0; i < threadNums; i++) { final int index = i; // 每一個執行緒負責一個queue,這樣不會出現執行緒搶佔佇列的情況。 new Thread(() -> { while (consumerRunning) { startConsumer = true; try { String str = blockQueueLists.get(index).take(); countNum(str); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } } // 按照arr的大小,運用多執行緒分割字串 private static void countNum(String str) { int[] arr = new int[2]; arr[1] = str.length() / 3; // System.out.println("分割的字串為start位置為:" + arr[0] + ",end位置為:" + arr[1]); for (int i = 0; i < 3; i++) { final String innerStr = SplitData.splitStr(str, arr); // System.out.println("分割的字串為start位置為:" + arr[0] + ",end位置為:" + arr[1]); new Thread(() -> { String[] strArray = innerStr.split(","); for (String s : strArray) { countMap.computeIfAbsent(s, s1 -> new AtomicInteger(0)).getAndIncrement(); } }).start(); } } /** * 後臺執行緒去消費map裡資料寫入到各個檔案裡, 如果不消費,那麼會將記憶體程爆 */ private static void startConsumer0() throws FileNotFoundException, UnsupportedEncodingException { for (int i = start; i <= end; i++) { final int index = i; BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(dir + "\" + i + ".dat", false), "utf-8")); new Thread(() -> { int miss = 0; int countIndex = 0; while (true) { // 每隔100萬列印一次 int count = countMap.get(index).get(); if (count > 1000000 * countIndex) { System.out.println(index + "歲年齡的個數為:" + countMap.get(index).get()); countIndex += 1; } if (miss > 1000) { // 終止執行緒 try { Thread.currentThread().interrupt(); bw.close(); } catch (IOException e) { } } if (Thread.currentThread().isInterrupted()) { break; } Vector<String> lines = valueMap.computeIfAbsent(index, vector -> new Vector<>()); // 寫入到檔案裡 try { if (CollectionUtil.isEmpty(lines)) { miss++; Thread.sleep(1000); } else { // 100個一批 if (lines.size() < 1000) { Thread.sleep(1000); continue; } // 1000個的時候開始處理 ReentrantLock lock = lockMap.computeIfAbsent(index, lockIndex -> new ReentrantLock()); lock.lock(); try { Iterator<String> iterator = lines.iterator(); StringBuilder sb = new StringBuilder(); while (iterator.hasNext()) { sb.append(iterator.next()); countMap.get(index).addAndGet(1); } try { bw.write(sb.toString()); bw.flush(); } catch (IOException e) { e.printStackTrace(); } // 清除掉vector valueMap.put(index, new Vector<>()); } finally { lock.unlock(); } } } catch (InterruptedException e) { } } }).start(); } } }
記憶體和 CPU 初始佔用大小:
啟動後,執行時記憶體穩定在 11.7G,CPU 穩定利用在 90% 以上。
總耗時由 180 秒縮減到 103 秒,效率提升 75%,得到的結果也與單執行緒處理的一致。
如果在執行了的時候,發現 GC 突然罷工不工作了,有可能是 JVM 的堆中存在的垃圾太多,沒回收導致記憶體的突增。
在讀取一定數量後,可以讓主執行緒暫停幾秒,手動呼叫 GC。
到此這篇關於如何用 Java 幾分鐘處理完 30 億個資料的文章就介紹到這了,更多相關Java 處理資料內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45