JUC 常用 4 大并发工具类是哪几个?(面试必问!)

编程入门 行业动态 更新时间:2024-10-28 06:37:04

JUC 常用 4 大并发工具类是哪<a href=https://www.elefans.com/category/jswz/34/1769605.html style=几个?(面试必问!)"/>

JUC 常用 4 大并发工具类是哪几个?(面试必问!)

点击上方“Java基基”,选择“设为星标”

做积极的人,而不是积极废人!

每天 14:00 更新文章,每天掉亿点点头发...

源码精品专栏

 
  • 原创 | Java 2021 超神之路,很肝~

  • 中文详细注释的开源项目

  • RPC 框架 Dubbo 源码解析

  • 网络应用框架 Netty 源码解析

  • 消息中间件 RocketMQ 源码解析

  • 数据库中间件 Sharding-JDBC 和 MyCAT 源码解析

  • 作业调度中间件 Elastic-Job 源码解析

  • 分布式事务中间件 TCC-Transaction 源码解析

  • Eureka 和 Hystrix 源码解析

  • Java 并发源码

来源:cnblogs/flower-dance/

p/13714006.html

  • 「4大常用并发工具类:」

  • 「CountDownLatch:」

  • 「CyclicBarrier:」

  • 「Semaphore:」

  • 「Exchanger:」


「什么是JUC?」

JUC 就是 java.util.concurrent 包,这个包俗称 JUC,里面都是解决并发问题的一些东西。

该包的位置位于 java 下面的 rt.jar 包下面

「4大常用并发工具类:」

  • CountDownLatch

  • CyclicBarrier

  • Semaphore

  • ExChanger

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能。

项目地址:

「CountDownLatch:」

CountDownLatch,俗称闭锁,作用是类似加强版的 Join,是让一组线程等待其他的线程完成工作以后才执行。

就比如在启动框架服务的时候,我们主线程需要在环境线程初始化完成之后才能启动,这时候我们就可以实现使用 CountDownLatch 来完成。

/*** Constructs a {@code CountDownLatch} initialized with the given count.** @param count the number of times {@link #countDown} must be invoked*        before threads can pass through {@link #await}* @throws IllegalArgumentException if {@code count} is negative*/
public CountDownLatch(int count) {if (count < 0) throw new IllegalArgumentException("count < 0");this.sync = new Sync(count);
}

在源码中可以看到,创建 CountDownLatch 时,需要传入一个 int 类型的参数,将决定在执行次扣减之后,等待的线程被唤醒。

等待的线程被唤醒

通过这个类图就可以知道其实 CountDownLatch 并没有多少东西。

方法介绍:

  • CountDownLatch:初始化方法。

  • await:等待方法,同时带参数的是超时重载方法

  • countDown:每执行一次,计数器减一,就是初始化传入的数字,也代表着一个线程完成了任务

  • getCount:获取当前值

  • toString:这个就不用说了

里面的 Sync 是一个内部类,外面的方法其实都是操作这个内部类的,这个内部类继承了 AQS,实现的标准方法,AQS 将在后面的章节写。

Sync 是一个内部类

主线程中创建 CountDownLatch(3),然后主线程 await 阻塞,然后线程 A,B,C 各自完成了任务,调用了 countDown,之后,每个线程调用一次计数器就会减一,初始是 3,然后 A 线程调用后变成 2,B 线程调用后变成 1,C 线程调用后,变成 0,这时就会唤醒正在 await 的主线程,然后主线程继续执行。

说一千道一万,不如代码写几行,上代码:

休眠工具类,之后的代码都会用到

package org.dance.tools;import java.util.concurrent.TimeUnit;/*** 类说明:线程休眠辅助工具类*/
public class SleepTools {/*** 按秒休眠* @param seconds 秒数*/public static final void second(int seconds) {try {TimeUnit.SECONDS.sleep(seconds);} catch (InterruptedException e) {}}/*** 按毫秒数休眠* @param seconds 毫秒数*/public static final void ms(int seconds) {try {TimeUnit.MILLISECONDS.sleep(seconds);} catch (InterruptedException e) {}}
}
package org.dance.day2.util;import org.dance.tools.SleepTools;import java.util.concurrent.CountDownLatch;/*** CountDownLatch的使用,有五个线程,6个扣除点* 扣除完成后主线程和业务线程,才能执行工作*  扣除点一般都是大于等于需要初始化的线程的* @author ZYGisComputer*/
public class UseCountDownLatch {/*** 设置为6个扣除点*/static CountDownLatch countDownLatch = new CountDownLatch(6);/*** 初始化线程*/private static class InitThread implements Runnable {@Overridepublic void run() {System.out.println("thread_" + Thread.currentThread().getId() + " ready init work .....");// 执行扣减 扣减不代表结束countDownLatch.countDown();for (int i = 0; i < 2; i++) {System.out.println("thread_" + Thread.currentThread().getId() + ".....continue do its work");}}}/*** 业务线程*/private static class BusiThread implements Runnable {@Overridepublic void run() {// 业务线程需要在等初始化完毕后才能执行try {countDownLatch.await();for (int i = 0; i < 3; i++) {System.out.println("BusiThread " + Thread.currentThread().getId() + " do business-----");}} catch (InterruptedException e) {e.printStackTrace();}}}public static void main(String[] args) {// 创建单独的初始化线程new Thread(){@Overridepublic void run() {SleepTools.ms(1);System.out.println("thread_" + Thread.currentThread().getId() + " ready init work step 1st.....");// 扣减一次countDownLatch.countDown();System.out.println("begin stop 2nd.....");SleepTools.ms(1);System.out.println("thread_" + Thread.currentThread().getId() + " ready init work step 2nd.....");// 扣减一次countDownLatch.countDown();}}.start();// 启动业务线程new Thread(new BusiThread()).start();// 启动初始化线程for (int i = 0; i <= 3; i++) {new Thread(new InitThread()).start();}// 主线程进入等待try {countDownLatch.await();System.out.println("Main do ites work.....");} catch (InterruptedException e) {e.printStackTrace();}}
}

返回结果:

thread_13 ready init work .....
thread_13.....continue do its work
thread_13.....continue do its work
thread_14 ready init work .....
thread_14.....continue do its work
thread_14.....continue do its work
thread_15 ready init work .....
thread_15.....continue do its work
thread_11 ready init work step 1st.....
begin stop 2nd.....
thread_16 ready init work .....
thread_16.....continue do its work
thread_16.....continue do its work
thread_15.....continue do its work
thread_11 ready init work step 2nd.....
Main do ites work.....
BusiThread 12 do business-----
BusiThread 12 do business-----
BusiThread 12 do business-----

通过返回结果就可以很直接的看到业务线程是在初始化线程完全跑完之后,才开始执行的。

基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。

项目地址:

「CyclicBarrier:」

CyclicBarrier,俗称栅栏锁,作用是让一组线程到达某个屏障,被阻塞,一直到组内的最后一个线程到达,然后屏障开放,接着,所有的线程继续运行。

这个感觉和 CountDownLatch 有点相似,但是其实是不一样的,所谓的差别,将在下面详解。

CyclicBarrier 的构造参数有两个。

/*** Creates a new {@code CyclicBarrier} that will trip when the* given number of parties (threads) are waiting upon it, and* does not perform a predefined action when the barrier is tripped.** @param parties the number of threads that must invoke {@link #await}*        before the barrier is tripped* @throws IllegalArgumentException if {@code parties} is less than 1*/
public CyclicBarrier(int parties) {this(parties, null);
}
/*** Creates a new {@code CyclicBarrier} that will trip when the* given number of parties (threads) are waiting upon it, and which* will execute the given barrier action when the barrier is tripped,* performed by the last thread entering the barrier.** @param parties the number of threads that must invoke {@link #await}*        before the barrier is tripped* @param barrierAction the command to execute when the barrier is*        tripped, or {@code null} if there is no action* @throws IllegalArgumentException if {@code parties} is less than 1*/
public CyclicBarrier(int parties, Runnable barrierAction) {if (parties <= 0) throw new IllegalArgumentException();this.parties = parties;this.count = parties;this.barrierCommand = barrierAction;
}

很明显能感觉出来,上面的构造参数调用了下面的构造参数,是一个构造方法重载。

首先这个第一个参数也树 Int 类型的,传入的是执行线程的个数,这个数量和 CountDownLatch 不一样,这个数量是需要和线程数量吻合的,CountDownLatch 则不一样,CountDownLatch 可以大于等于,而 CyclicBarrier 只能等于,然后是第二个参数,第二个参数是 barrierAction,这个参数是当屏障开放后,执行的任务线程,如果当屏障开放后需要执行什么任务,可以写在这个线程中。

CyclicBarrier

主线程创建 CyclicBarrier(3,barrierAction),然后由线程开始执行,线程A,B 执行完成后都调用了 await,然后他们都在一个屏障前阻塞者,需要等待线程 C 也,执行完成,调用 await 之后,然后三个线程都达到屏障后,屏障开放,然后线程继续执行,并且 barrierAction 在屏障开放的一瞬间也开始执行。

上代码:

package org.dance.day2.util;import org.dance.tools.SleepTools;import java.util.Map;
import java.util.Random;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CyclicBarrier;/*** CyclicBarrier的使用** @author ZYGisComputer*/
public class UseCyclicBarrier {/*** 存放子线程工作结果的安全容器*/private static ConcurrentHashMap<String, Long> resultMap = new ConcurrentHashMap<>();private static CyclicBarrier cyclicBarrier = new CyclicBarrier(5,new CollectThread());/*** 结果打印线程* 用来演示CyclicBarrier的第二个参数,barrierAction*/private static class CollectThread implements Runnable {@Overridepublic void run() {StringBuffer result = new StringBuffer();for (Map.Entry<String, Long> workResult : resultMap.entrySet()) {result.append("[" + workResult.getValue() + "]");}System.out.println("the result = " + result);System.out.println("do other business.....");}}/*** 工作子线程* 用于CyclicBarrier的一组线程*/private static class SubThread implements Runnable {@Overridepublic void run() {// 获取当前线程的IDlong id = Thread.currentThread().getId();// 放入统计容器中resultMap.put(String.valueOf(id), id);Random random = new Random();try {if (random.nextBoolean()) {Thread.sleep(1000 + id);System.out.println("Thread_"+id+"..... do something");}System.out.println(id+" is await");cyclicBarrier.await();Thread.sleep(1000+id);System.out.println("Thread_"+id+".....do its business");} catch (InterruptedException e) {e.printStackTrace();} catch (BrokenBarrierException e) {e.printStackTrace();}}}public static void main(String[] args) {for (int i = 0; i <= 4; i++) {Thread thread = new Thread(new SubThread());thread.start();}}
}

返回结果:

11 is await
14 is await
15 is await
Thread_12..... do something
12 is await
Thread_13..... do something
13 is await
the result = [11][12][13][14][15]
do other business.....
Thread_11.....do its business
Thread_12.....do its business
Thread_13.....do its business
Thread_14.....do its business
Thread_15.....do its business

通过返回结果可以看出前面的 11 14 15 三个线程没有进入 if 语句块,在执行到 await 的时候进入了等待,而另外 12 13 两个线程进入到了 if 语句块当中,多休眠了 1 秒多,然后当 5 个线程同时到达 await 的时候,屏障开放,执行了 barrierAction 线程,然后线程组继续执行。

解释一下 CountDownLatch 和 CyclicBarrier 的区别吧!

首先就是 CountDownLatch 的构造参数传入的数量一般都是大于等于线程,数量的,因为他是有第三方控制的,可以扣减多次,然后就是 CyclicBarrier 的构造参数第一个参数传入的数量一定是等于线程的个数的,因为他是由一组线程自身控制的。

CountDownLatch 和 CyclicBarrier 的区别

「Semaphore:」

Semaphore,俗称信号量,作用于控制同时访问某个特定资源的线程数量,用在流量控制

一说特定资源控制,那么第一时间就想到了数据库连接..

之前用等待超时模式写了一个数据库连接池,打算用这个Semaphone也写一个

/*** Creates a {@code Semaphore} with the given number of* permits and nonfair fairness setting.** @param permits the initial number of permits available.*        This value may be negative, in which case releases*        must occur before any acquires will be granted.*/
public Semaphore(int permits) {sync = new NonfairSync(permits);
}

在源码中可以看到在构建 Semaphore 信号量的时候,需要传入许可证的数量,这个数量就是资源的最大允许的访问的线程数。

接下里用信号量实现一个数据库连接池。

连接对象。

package org.dance.day2.util.pool;import org.dance.tools.SleepTools;import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.Executor;/*** 数据库连接* @author ZYGisComputer*/
public class SqlConnection implements Connection {/*** 获取数据库连接* @return*/public static final Connection fetchConnection(){return new SqlConnection();}@Overridepublic void commit() throws SQLException {SleepTools.ms(70);}@Overridepublic Statement createStatement() throws SQLException {SleepTools.ms(1);return null;}@Overridepublic PreparedStatement prepareStatement(String sql) throws SQLException {return null;}@Overridepublic CallableStatement prepareCall(String sql) throws SQLException {return null;}@Overridepublic String nativeSQL(String sql) throws SQLException {return null;}@Overridepublic void setAutoCommit(boolean autoCommit) throws SQLException {}@Overridepublic boolean getAutoCommit() throws SQLException {return false;}@Overridepublic void rollback() throws SQLException {}@Overridepublic void close() throws SQLException {}@Overridepublic boolean isClosed() throws SQLException {return false;}@Overridepublic DatabaseMetaData getMetaData() throws SQLException {return null;}@Overridepublic void setReadOnly(boolean readOnly) throws SQLException {}@Overridepublic boolean isReadOnly() throws SQLException {return false;}@Overridepublic void setCatalog(String catalog) throws SQLException {}@Overridepublic String getCatalog() throws SQLException {return null;}@Overridepublic void setTransactionIsolation(int level) throws SQLException {}@Overridepublic int getTransactionIsolation() throws SQLException {return 0;}@Overridepublic SQLWarning getWarnings() throws SQLException {return null;}@Overridepublic void clearWarnings() throws SQLException {}@Overridepublic Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {return null;}@Overridepublic PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {return null;}@Overridepublic CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {return null;}@Overridepublic Map<String, Class<?>> getTypeMap() throws SQLException {return null;}@Overridepublic void setTypeMap(Map<String, Class<?>> map) throws SQLException {}@Overridepublic void setHoldability(int holdability) throws SQLException {}@Overridepublic int getHoldability() throws SQLException {return 0;}@Overridepublic Savepoint setSavepoint() throws SQLException {return null;}@Overridepublic Savepoint setSavepoint(String name) throws SQLException {return null;}@Overridepublic void rollback(Savepoint savepoint) throws SQLException {}@Overridepublic void releaseSavepoint(Savepoint savepoint) throws SQLException {}@Overridepublic Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {return null;}@Overridepublic PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {return null;}@Overridepublic CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {return null;}@Overridepublic PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException {return null;}@Overridepublic PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException {return null;}@Overridepublic PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {return null;}@Overridepublic Clob createClob() throws SQLException {return null;}@Overridepublic Blob createBlob() throws SQLException {return null;}@Overridepublic NClob createNClob() throws SQLException {return null;}@Overridepublic SQLXML createSQLXML() throws SQLException {return null;}@Overridepublic boolean isValid(int timeout) throws SQLException {return false;}@Overridepublic void setClientInfo(String name, String value) throws SQLClientInfoException {}@Overridepublic void setClientInfo(Properties properties) throws SQLClientInfoException {}@Overridepublic String getClientInfo(String name) throws SQLException {return null;}@Overridepublic Properties getClientInfo() throws SQLException {return null;}@Overridepublic Array createArrayOf(String typeName, Object[] elements) throws SQLException {return null;}@Overridepublic Struct createStruct(String typeName, Object[] attributes) throws SQLException {return null;}@Overridepublic void setSchema(String schema) throws SQLException {}@Overridepublic String getSchema() throws SQLException {return null;}@Overridepublic void abort(Executor executor) throws SQLException {}@Overridepublic void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException {}@Overridepublic int getNetworkTimeout() throws SQLException {return 0;}@Overridepublic <T> T unwrap(Class<T> iface) throws SQLException {return null;}@Overridepublic boolean isWrapperFor(Class<?> iface) throws SQLException {return false;}
}

连接池对象

package org.dance.day2.util.pool;import java.sql.Connection;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.concurrent.Semaphore;/*** 使用信号量控制数据库的链接和释放** @author ZYGisComputer*/
public class DBPoolSemaphore {/*** 池容量*/private final static int POOL_SIZE = 10;/*** useful 代表可用连接* useless 代表已用连接*  为什么要使用两个Semaphore呢?是因为,在连接池中不只有连接本身是资源,空位也是资源,也需要记录*/private final Semaphore useful, useless;/*** 连接池*/private final static LinkedList<Connection> POOL = new LinkedList<>();/*** 使用静态块初始化池*/static {for (int i = 0; i < POOL_SIZE; i++) {POOL.addLast(SqlConnection.fetchConnection());}}public DBPoolSemaphore() {// 初始可用的许可证等于池容量useful = new Semaphore(POOL_SIZE);// 初始不可用的许可证容量为0useless = new Semaphore(0);}/*** 获取数据库连接** @return 连接对象*/public Connection takeConnection() throws InterruptedException {// 可用许可证减一useful.acquire();Connection connection;synchronized (POOL) {connection = POOL.removeFirst();}// 不可用许可证数量加一useless.release();return connection;}/*** 释放链接** @param connection 连接对象*/public void returnConnection(Connection connection) throws InterruptedException {if(null!=connection){// 打印日志System.out.println("当前有"+useful.getQueueLength()+"个线程等待获取连接,,"+"可用连接有"+useful.availablePermits()+"个");// 不可用许可证减一useless.acquire();synchronized (POOL){POOL.addLast(connection);}// 可用许可证加一useful.release();}}
}

测试类:

package org.dance.day2.util.pool;import org.dance.tools.SleepTools;import java.sql.Connection;
import java.util.Random;/*** 测试Semaphore* @author ZYGisComputer*/
public class UseSemaphore {/*** 连接池*/public static final DBPoolSemaphore pool = new DBPoolSemaphore();private static class BusiThread extends Thread{@Overridepublic void run() {// 随机数工具类 为了让每个线程持有连接的时间不一样Random random = new Random();long start = System.currentTimeMillis();try {Connection connection = pool.takeConnection();System.out.println("Thread_"+Thread.currentThread().getId()+"_获取数据库连接耗时["+(System.currentTimeMillis()-start)+"]ms.");// 模拟使用连接查询数据SleepTools.ms(100+random.nextInt(100));System.out.println("查询数据完成归还连接");pool.returnConnection(connection);} catch (InterruptedException e) {e.printStackTrace();}}}public static void main(String[] args) {for (int i = 0; i < 50; i++) {BusiThread busiThread = new BusiThread();busiThread.start();}}
}

测试返回结果:

Thread_11_获取数据库连接耗时[0]ms.
Thread_12_获取数据库连接耗时[0]ms.
Thread_13_获取数据库连接耗时[0]ms.
Thread_14_获取数据库连接耗时[0]ms.
Thread_15_获取数据库连接耗时[0]ms.
Thread_16_获取数据库连接耗时[0]ms.
Thread_17_获取数据库连接耗时[0]ms.
Thread_18_获取数据库连接耗时[0]ms.
Thread_19_获取数据库连接耗时[0]ms.
Thread_20_获取数据库连接耗时[0]ms.
查询数据完成归还连接
当前有40个线程等待获取连接,,可用连接有0个
Thread_21_获取数据库连接耗时[112]ms.
查询数据完成归还连接
...................
查询数据完成归还连接
当前有2个线程等待获取连接,,可用连接有0个
Thread_59_获取数据库连接耗时[637]ms.
查询数据完成归还连接
当前有1个线程等待获取连接,,可用连接有0个
Thread_60_获取数据库连接耗时[660]ms.
查询数据完成归还连接
当前有0个线程等待获取连接,,可用连接有0个
查询数据完成归还连接
...................
当前有0个线程等待获取连接,,可用连接有8个
查询数据完成归还连接
当前有0个线程等待获取连接,,可用连接有9个

通过执行结果可以很明确的看到,一上来就有10个线程获取到了连接,,然后后面的40 个线程进入阻塞,然后只有释放链接之后,等待的线程就会有一个拿到,然后越后面的线程等待的时间就越长,然后一直到所有的线程执行完毕

最后打印的可用连接有九个不是因为少了一个是因为在释放之前打印的,不是错误

从结果中可以看到,我们对连接池中的资源的到了控制,这就是信号量的流量控制

「Exchanger:」

Exchanger,俗称交换器,用于在线程之间交换数据,但是比较受限,因为只能两个线程之间交换数据

/*** Creates a new Exchanger.*/
public Exchanger() {participant = new Participant();
}

这个构造函数没有什么好说的,也没有入参,只有在创建的时候指定一下需要交换的数据的泛型即可,下面看代码

package org.dance.day2.util;import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Exchanger;/*** 线程之间交换数据* @author ZYGisComputer*/
public class UseExchange {private static final Exchanger<Set<String>> exchanger = new Exchanger<>();public static void main(String[] args) {new Thread(){@Overridepublic void run() {Set<String> aSet = new HashSet<>();aSet.add("A");aSet.add("B");aSet.add("C");try {Set<String> exchange = exchanger.exchange(aSet);for (String s : exchange) {System.out.println("aSet"+s);}} catch (InterruptedException e) {e.printStackTrace();}}}.start();new Thread(){@Overridepublic void run() {Set<String> bSet = new HashSet<>();bSet.add("1");bSet.add("2");bSet.add("3");try {Set<String> exchange = exchanger.exchange(bSet);for (String s : exchange) {System.out.println("bSet"+s);}} catch (InterruptedException e) {e.printStackTrace();}}}.start();}
}

执行结果:

bSetA
bSetB
bSetC
aSet1
aSet2
aSet3

通过执行结果可以清晰的看到,两个线程中的数据发生了交换,这就是 Exchanger 的线程数据交换了。

以上就是 JUC 的 4 大常用并发工具类了。



欢迎加入我的知识星球,一起探讨架构,交流源码。加入方式,长按下方二维码噢

已在知识星球更新源码解析如下:

最近更新《芋道 SpringBoot 2.X 入门》系列,已经 101 余篇,覆盖了 MyBatis、Redis、MongoDB、ES、分库分表、读写分离、SpringMVC、Webflux、权限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能测试等等内容。

提供近 3W 行代码的 SpringBoot 示例,以及超 6W 行代码的电商微服务项目。

获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。

文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)

更多推荐

JUC 常用 4 大并发工具类是哪几个?(面试必问!)

本文发布于:2024-02-26 00:13:19,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1700722.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:几个   常用   工具   JUC   类是哪

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!