f 【面试】Java 多线程 - qidao123.com技术社区-IT企服评测·应用市场 - Powered by Discuz!

qidao123.com技术社区-IT企服评测·应用市场

标题: 【面试】Java 多线程 [打印本页]

作者: 悠扬随风    时间: 2025-4-16 09:05
标题: 【面试】Java 多线程
1、什么是线程和进程

是包含了某些资源的内存地区,操纵系统使用进程把它的工作划分为一些功能单元。电脑中时会有很多单独运行的程序,每个程序有一个独立的进程。比方微信,IDEA,GOOGLE等等。
进程中包含的一个或多个执行单元称为线程,线程只能归属一个进程,并且线程只能访问该进程拥有的资源。当操纵系统创建一个进程,该进程会主动申请一个主线程作为主要的执行任务。线程的切换耗时小,把线程称为轻负荷线程。一个进程由一个或多个线程组成,彼此间完成不同的工作,多个线程同时执行,称为多线程


2、创建线程有几种方式

  1. class TicketThread extends Thread {
  2.     private int tickets = 30;
  3.     @Override
  4.     public void run() {
  5.         while (true) {
  6.             if (tickets > 0) {
  7.                 System.out.println(Thread.currentThread().getName() + "正在买票" + tickets--);
  8.             } else {
  9.                 System.out.println(Thread.currentThread().getName() + "票卖完了");
  10.                 break;
  11.             }
  12.         }
  13.     }
  14. }
复制代码
创建线程对象和启动线程
  1. // 创建一个线程对象
  2. TicketThread t = new TicketThread();
  3. // 启动线程
  4. t.start();
复制代码
  1. class TicketThread implements Runnable {
  2.     private int tickets = 30;
  3.     @Override
  4.     public void run() {
  5.         while (true) {
  6.             if (tickets > 0) {
  7.                 System.out.println(Thread.currentThread().getName() + "正在买票" + tickets--);
  8.             } else {
  9.                 System.out.println(Thread.currentThread().getName() + "票卖完了");
  10.                 break;
  11.             }
  12.         }
  13.     }
  14. }
复制代码
创建线程对象和启动线程
  1. // 创建一个任务对象
  2. Runnable runnable = new TicketThread();
  3. // 创建一个线程对象
  4. Thread t = new Thread(runnable);
  5. // 启动线程
  6. t.start();
复制代码
  1. class TicketThread implements Callable<List<String>> {
  2.     private int tickets = 30;
  3.     List<String> list = new ArrayList<String>();
  4.     @Override
  5.     public List<String> call() throws Exception {
  6.         while (true) {
  7.             if (tickets > 0) {
  8.                 list.add(Thread.currentThread().getName() + "正在买票" + tickets--);
  9.             } else {
  10.                 list.add(Thread.currentThread().getName() + "票卖完了");
  11.                 return list;
  12.             }
  13.         }
  14.     }
  15. }
复制代码
创建线程对象和启动线程
  1. Callable callable = new TicketThread();
  2. FutureTask futureTask = new FutureTask<>(callable);
  3. new Thread(futureTask).start();
  4. // 获取返回值
  5. List<String> list = (List<String>)futureTask.get();
  6. for (String s : list) {
  7.     System.out.println(s);
  8. }
复制代码
  1. public class H {
  2.     public static void main(String[] args) {
  3.         // 1.创建一个单线程的线程池,这个线程池只有一个线程在工作,即单线程执行任务,如果这个唯一的线程因为异常结束,那么就会有一个新的线程来替代它因此线程池保证所有的任务是按照任务的提交顺序来执行。
  4.         // ExecutorService service = Executors.newSingleThreadScheduledExecutor();
  5.         // 2.创建一个固定大小的线程池,每次提交一个任务就创建一个线程直到达到线程池的最大的大小,线程池的大小一旦达到最大就会保持不变,如果某个线程因为执行异常而结束,那么就会补充一个新的线程。
  6.         // ExecutorService service = Executors.newFixedThreadPool(5);
  7.         // 3.创建一个可以缓冲的线程池,如果线程大小超过处理任务所需的线程,那么就会回收部分线程,当线程数增加的时候此线程池不会对线程池大小做限制,线程池的大小完全依赖操作系统能够创建的做大做小。
  8.         // ExecutorService service = Executors.newCachedThreadPool();
  9.         // 4.周期性线程池创建,此线程池支持定时以及周期性的执行任务的需求。
  10.         // 5.手动创建线程池。
  11.         ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 30L, TimeUnit.SECONDS,
  12.             new ArrayBlockingQueue<Runnable>(5), new RejectedExecutionHandler() {
  13.                 // 回调方法
  14.                 public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
  15.                     System.out.println("线程数超过了线程池容量,拒绝执行任务-->" + r);
  16.                 }
  17.             });
  18.         // 执行10个任务
  19.         for (int i = 0; i < 10; i++) {
  20.             threadPool.execute(new Runnable() {
  21.                 public void run() {
  22.                     System.out.println("线程名是:" + Thread.currentThread().getName());
  23.                 }
  24.             });
  25.         }
  26.     }
  27. }
复制代码
Thread 和 Runnable 两种开辟线程的区别
Runnable 和 Callable 区别
FutureTask 和 Callable 示例
  1. Callable<String> callable = () -> {
  2.     System.out.println("Entered Callable");
  3.     Thread.sleep(2000);
  4.     return "Hello from Callable";
  5. };
  6. FutureTask<String> futureTask = new FutureTask<>(callable);
  7. Thread thread = new Thread(futureTask);
  8. thread.start();
  9. System.out.println("Do something else while callable is getting executed");
  10. System.out.println("Retrieved:" + futureTask.get());
复制代码
线程池和Callable的示例
  1. ExecutorService executor = Executors.newSingleThreadExecutor();
  2. Callable<String> callable = () -> {
  3.     System.out.println("Entered Callable");
  4.     Thread.sleep(2000);
  5.     return "Hello from Callable";
  6. };
  7. System.out.println("Submitting Callable");
  8. Future<String> future = executor.submit(callable);
  9. System.out.println("Do something else while callable is getting executed");
  10. System.out.println("Retrieved:" + future.get());
  11. executor.shutdown();
复制代码
3、线程有几种状态

线程是怎样被调度的
进程是分配资源的基本单元,线程是CPU调度的基本单元。这里所说的调度指的就是给其分配CPU时间片,让其
执行任务。
run方法和start方法区别
我们创建好线程之后,想要启动这个线程,则需要调用其start方法。以是,start方法是启动一个线程的入口。如果在创建好线程之后,直接调用其run方法,那么就会在单线程中直接运行run方法,不会起到多线程的效果。
WAITING 和 TIMED WAIT 的区别
WAITING是等待状态,在Java中,调用wait方法时,线程会进入到WAITING 状态,而TIMED WAITING是超时等
待状态,当线程执行sleep方法时,线程会进入TIMED WAIT状态。
sleep和wait区别
notity和notityAll区别
当一个线程进入wait之后,就必须等其他线程notify大概notifyAll才会从等待队列中被移出。使用notifyAll可以叫醒所有处于wait状态的线程,使其重新进入锁的争取队列中,而notify只能叫醒一个。被notify/notifyAll叫醒的线程,只是表示他们可以竞争锁了,竞争到锁之后才有时机被CPU调度。
Thread.sleep(0)的作用是什么
sleep方法需要指定一个时间,表示sleep的毫秒数,但是有的时间我们会见到Thread.sleep(0)这种用法着实就是让当前线程开释一下CPU时间片,然后重新开始争抢。
线程优先级
虽然Java线程调度是系统主动完成的,但是我们还是可以"建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点,这项操纵可以通过设置线程优先级来完成。
方法描述static Thread currentThread()获取当前线程对象(线程名称, 线程优先级, 线程所属线程组)String getName()获取当前线程对象viod set(String name)设置线程名称int getId()获取线程id int getPriority(int i)设置线程级别static Thread currentThread()获取当前线程对象void setDaemon(boolean bo)设置一个线程为守护(后台)线程boolean isDaemon()获取守护线程是true还是falsestatic native void sleep(long millis)设置休眠(单位毫秒)static native void yield()当前线程放弃时间片void join()等待该线程停止void wait()设置当前线程等待壅闭状态void notify()叫醒正处于等待状态的线程void notifyAll()叫醒所有处于等待状态的线程 4、什么是上下文切换

多线程编程中一样平常线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有用执行,CPU 接纳的策略是为每个线程分配时间片并轮转的情势。
一个线程被剥夺CPU的使用权就是 “切出”,一个线程得到CPU的使用权就是 “切入”,这种切入切出过程就是上线文。当一个线程的时间片用完的时间就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
在多线程中,上下文切换的开销比单线程大,由于在多线程中,需要生存和恢复更多的上下文信息。过多上下文切换会降低系统的运行效率,因此需要尽大概减少上下文切换的次数。
减少上下文的切换的方式
5、什么是守护线程,和普通线程有什么区别

在Java中有两类线程:User Thread用户线程、Daemon Thread守护线程。用户线程一样平常用户执行用户级任务,而守护线程也就是后台线程,一样平常用来执行后台任务,守护线程最典型的应用就是GC垃圾接纳器。
这两种线程着实是没有什么区别的,唯一的区别就是虚拟机在所有用户线程都竣事后就会退出,而不会等守护线程执行完。
  1. Thread t1 = new Thread();
  2. t1.setDaemon(true);
  3. System.out.println(t1.isDaemon());
复制代码
6、什么是线程池,怎样实现的

顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立刻被销毁,而是等待下一个任务。
为什么要使用线程池
线程池的使用
(1)通过 Executor 框架的工具类 Executors 来创建
Executors的创建线程池的方法,创建出来的线程池都实现了ExecutorService:接口。常用方法有以下几个:

(2)通过ThreadPoolExecutor构造函数来创建
  1. ExecutorService executor = new ThreadPoolExecutor(10, 10, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10));
复制代码
(3)ThreadPoolTaskExecutor是对ThreadPoolExecutor进行了封装处理
  1. ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  2. // 最大线程数
  3. executor.setCorePoolSize(corePoolSize);
  4. // 核心线程数
  5. executor.setMaxPoolSize(maxPoolSize);
  6. // 任务队列的大小
  7. executor.setQueueCapacity(queueCapacity);
  8. // 线程池名的前缀
  9. executor.setThreadNamePrefix(namePrefix);
复制代码
7、Executor和Executors的区别

Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。
Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能得到任务执行的状态并且可以获取任务的返回值。
使用ThreadPoolExecutor 可以创建自定义线程池。Future 表示异步计算的效果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用get()方法获取计算的效果。
8、线程池处理任务的流程


线程池工作流程



当线程池和队列都满时,触发 handler 拒绝策略。
线程池的拒绝策略有那些

线程池常用的壅闭队列有哪些

9、线程数设定成多少更合适

一样平常环境下,需要根据你的任务环境来设置线程数,任务大概是两种范例,分别是CPU密集型和IO密集型。

CPU密集的意思是该任务需要大量的运算,而没有壅闭,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才大概得到加速(通过多线程),而在单核CPU上,无论你开几个模仿的多线程该任务都不大概得到加速,由于CPU总的运算本领就那些。IO包括数据库交互,文件上传下载,网络传输等。
10、执行execute方法和submit方法的区别



11、什么是ThreadLocal,怎样实现的

ThreadLocal 是用来办理java多线程程序中并发问题的一种途径,是java中的一个线程本地变量,在多线程环境下维护每个下线程的独立数据副本,通过为每一个线程创建一份共享变量的副原来保证各个线程之间的变量的访问和修改互相不影响。
ThreadLocal有四个方法,分别为:

ThreadLocal原理
  1. public void set(T value) {
  2.     // 获取当前请求的线程
  3.     Thread t = Thread.currentThread();
  4.     // 取出 Thread 类内部的 threadLocals 变量(哈希表结构)
  5.     ThreadLocalMap map = getMap(t);
  6.     if (map != null)
  7.         // 将需要存储的值放入到这个哈希表中
  8.         map.set(this, value);
  9.     else
  10.         createMap(t, value);
  11. }
  12. ThreadLocalMap getMap(Thread t) {
  13.     return t.threadLocals;
  14. }
复制代码
通过上面这些内容,我们足以通过推测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。
  1. ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  2.     //......
  3. }
复制代码
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。
ThreadLocal 数据结构如下图所示:

ThreadLocal中用于生存线程的独有变量的数据结构是一个内部类:ThreadLocalMap,也是k-v结构key就是当前的ThreadLoacaly对象,而v就是我们想要生存的值。

上图中基本描述出了Thread、ThreadLocalMapl以及ThreadLocal三者之间的包含关系。
ThreadLocal 内存泄露问题
了解了ThreadLocal的基本原理之后,我们把上面的图补全,从堆栈的视角整体看一下他们之间的引用关系。

ThreadLocal对象,是有两个引用的,一个是栈上的ThreadLocal引用一个是ThreadLocalMap中的Key对他的引用。
那么,假如,栈上的ThreadLocal引用不在使用了,即方法竣事后这个对象引用就不再用了,那么,ThreadLocal
对象由于另有一条引用链在,以是就会导致他无法被接纳,久而久之大概就会对导致OOM。这就是我们所说的ThreadLocal的内存泄露问题。

缘故原由是 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。以是,如果 ThreadLocal 没有被外部强引用的环境下,在垃圾接纳的时间,key 会被清理掉,而 value 不会被清理掉。他的生命周期是和Thread 一样的,也就是说,只要这个Thread还在,这个对象就无法被接纳。
那么,什么环境下,Thread会一直在呢?那就是线程池。
在线程池中,重复使用线程的时间,就会导致这个引用一直在,而value就一直无法被接纳。那么怎样办理呢?
ThreadLocalMap底层使用数组来生存元素,使用"线性探测法”来办理hash辩论的,在每次调用ThreadLocal的
get、set、remove等方法的时间,内部会现实调用ThreadLocalMap的get、set、remove等操纵。而ThreadLocalMap的每次get、set、remove,都会清理逾期的Entry。.
以是,当我们在一个ThreadLocall用完之后,手动调用一下remove,就可以在下一次GC的时间,把Entryi清理
掉。
12、父子线程之间怎么共享数据

当我们在同一个线程中,想要共享变量的话,是可以直接使用ThreadLocal的,但是如果在父子线程之间,共享变
量,ThreadLocal就不可了。
  1. public class TestYang {
  2.     public static ThreadLocal<Integer> sharedData = new ThreadLocal<>();
  3.     public static void main(String[] args) {
  4.         sharedData.set(0);// 主线程设置 0
  5.         MyThread thread = new MyThread(); // 定义子线程
  6.         thread.start();// 开启子线程
  7.         sharedData.set(sharedData.get() + 1); // 主线程设置 1
  8.         System.out.println("sharedData in main thread:" + sharedData.get());// 获取主线程的值 1
  9.     }
  10.     static class MyThread extends Thread {
  11.         @Override
  12.         public void run() {
  13.             System.out.println("sharedData in child thread:" + sharedData.get());// null
  14.             sharedData.set(sharedData.get() + 1);
  15.             System.out.println("sharedData in child thread after increment:" + sharedData.get());
  16.         }
  17.     }
  18. }
复制代码
由于ThreadLocal变量是为每个线程提供了独立的副本,因比不同线程之间只能访问它们本身的副本。那么,想要实现数据共享,主要有两个办法,第一个是本身传递,第二个是借助InheritableThreadLocal
InheritableThreadLocal
与ThreadLocal不同,InheritableThreadLocal可以在子线程中继承父线程中的值。在创建子线程时,子线程将复制父线程中的InheritableThreadLocal变量。我们把开头的示例中ThreadLocal改成InheritableThreadLocal就可以了:
  1. public class TestYang {
  2.     public static InheritableThreadLocal<Integer> sharedData = new InheritableThreadLocal<>();
  3.     public static void main(String[] args) {
  4.         sharedData.set(0);// 主线程设置 0
  5.         MyThread thread = new MyThread(); // 定义子线程
  6.         thread.start();// 开启子线程
  7.         sharedData.set(sharedData.get() + 1); // 主线程设置 1
  8.         System.out.println("sharedData in main thread:" + sharedData.get());// 获取主线程的值 1
  9.     }
  10.     static class MyThread extends Thread {
  11.         @Override
  12.         public void run() {
  13.             System.out.println("sharedData in child thread:" + sharedData.get());// 0
  14.             sharedData.set(sharedData.get() + 1);// 1
  15.             System.out.println("sharedData in child thread after increment:" + sharedData.get());// 1
  16.         }
  17.     }
  18. }
复制代码
13、线程同步的方式有哪些

线程同步指的就是让多个线程之间按照次序访问同一个共享资源,克制由于并发辩论导致的问题,主要有以下几种
方式:

14、synchronized 是怎么实现的

① synchronized 用法
synchronized 关键字办理的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法大概代码块在任意时刻只能有一个线程执行。
synchronized 关键字最主要的三种使用方式:
给当前对象实例加锁,进入同步代码前要得到 当前对象实例的锁 。
  1. synchronized void method() {
  2.   //业务代码
  3. }
复制代码
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要得到 当前 class 的锁。
这是由于静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
  1. synchronized void staic method() {
  2.   //业务代码
  3. }
复制代码
对括号里指定的对象/类加锁:

  1. synchronized(this) {
  2.   //业务代码
  3. }
复制代码
② synchronized 同步语句块的环境
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,此中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的竣事位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监督器 monitor 的持有权。
③ synchronized 修饰方法的的环境
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果是实例方法,JVM 会实行获取实例对象的锁。如果是静态方法,JVM 会实行获取当前 class 的锁。
15、synchronized 的锁升级过程是怎样的

在JDK1.6之后,synchronized锁的实现发生了一些变化,引入了"偏向锁”、"轻量级锁”和"重量级锁”三种不同的状态,用来适应不同场景下的锁竞争环境。
16、什么是锁消除和锁粗化

比如StringBuffer的append方法,由于append方法需要判断对象是否被占用,而如果代码不存在锁竞争,那么这部分的性能斲丧是偶然义的。于是虚拟机在即时编译的时间就会将上面的代码进行优化,也就是锁消除。
  1. @Override
  2. public synchronized StringBuffer append(String str) {
  3.     toStringCache = null;
  4.     super.append(str);
  5.     return this;
  6. }
复制代码
当发现一系列一连的操纵都对同一个对象反复加锁和解锁,甚至加锁操纵出如今循环体中的时间,会将加锁同
步的范围散(粗化)到整个操纵序列的外部。
  1. for(inti=0;i<100000;i++){
  2.         synchronized(this){
  3.                 do();
  4.         }
  5. }
复制代码
会被粗化成:
  1. synchronized(this){
  2.         for(inti=0;i<100000;i++){
  3.                 do();
  4.         }
  5. }
复制代码
17、synchronized 和 reentrantLock 区别

相同点是都是可重入锁,不同点如下
公平锁和非公平锁有什么区别
公平锁:每个线程获取锁的次序是按照线程访问锁的先后次序获取的,最前面的线程总是最先获取到锁。非公平锁:每个线程获取锁的次序是随机的,并不会遵循先来先得的规则,所有线程会竞争获取锁。
在 Java 语言中,锁 synchronized 和 ReentrantLock 默认都黑白公平锁,当然我们在创建 ReentrantLock 时,可以手动指定其为公平锁,但 synchronized 只能为非公平锁。
怎么创建公平锁
new ReentrantLock()默认创建的为非公平锁,如果要创建公平锁可以使用new ReentrantLock(true)。
lock()和lockInterruptibly()的区别
lock和lockInterruptibly的区别在于获取锁的途中如果所在的线程中断,Iock会忽略异常继续等待获取锁,而lockInterruptibly则会抛出InterruptedException异常。
tryLock()
tryLock(5,TimeUnit.SECONDS)表示获取锁的最大等待时间为5秒,期间会一直实行获取,而不是等待5秒之后再去获取锁。
reentrantLock 底层原理
  1. ReentrantLock asd = new ReentrantLock();
复制代码
ReentrantLock 锁是一个轻量级锁,底层着实就是用自旋锁实现的,当我们调用 lock 方法的时间,在内部着实调用了 Sync.lock 方法,Sync 继承了 AQS,AQS 内部有一个 volatile 范例的 state 属性,现实上多线程对锁的竞争体如今对 state 值写入的竞争。一旦 state 从 0 变为 1,代表有线程已经竞争到锁,那么别的线程则进入等待队列。通过CAS修改了 state,修改乐成标志本身乐成获取锁。如果CAS失败的话,调用 acquire 方法
AQS 的 lock 有两个实现方法,一个在 ReentrantLock 非公平锁,一个在公平锁,非公平锁调用 lock 方法通过 CAS 去更新 AQS 的 state 的值(锁的状态值),更新乐成就是得到锁可以执行。更新不乐成就将没得到锁的线程放入链表尾部,自旋等待状态被开释,开释了,用CAS得到锁
  1. // 所以在底层调用的其实是AQS的lock()方法,
  2. asd.lock();
复制代码
18、synchronized 和 Lock 有什么区别

19、CountDownLatch、CyclicBarrier、Semaphore

CountDownLatch、CyclicBarrier、Semaphore都是ava并发库中的同步辅助类,它们都可以用来协调多个线程
之间的执行。

CountDownLatch和CyclicBarrier区别
有三个线程T1、T2、T3怎样保证次序执行
想要让三个线程依次执行,并且严酷按照T1,T2,T3的次序的话,主要就是想办法让三个线程之间可以通信、大概可以排队。
想让多个线程之间可以通信,可以通过join方法实现,还可以通过CountDownLatch、CyclicBarrier和Semaphore来实现通信。想要让线程之间排队的话,可以通过线程池大概CompletableFuturel的方式来实现。
join
  1. final Thread thread1 = new Thread(new Runnable() {
  2.             @Override
  3.             public void run() {
  4.                 System.out.println(Thread.currentThread().getName() + "is Running.");
  5.             }
  6.         }, "T1");
  7.         final Thread thread2 = new Thread(new Runnable() {
  8.             @Override
  9.             public void run() {
  10.                 try {
  11.                     thread1.join();
  12.                 } catch (InterruptedException e) {
  13.                     System.out.println("join thread1 failed");
  14.                 }
  15.                 System.out.println(Thread.currentThread().getName() + "is Running.");
  16.             }
  17.         }, "T2");
  18.         Thread thread3 = new Thread(new Runnable() {
  19.             @Override
  20.             public void run() {
  21.                 try {
  22.                     thread2.join();
  23.                 } catch (InterruptedException e) {
  24.                     System.out.println("join thread1 failed");
  25.                 }
  26.                 System.out.println(Thread.currentThread().getName() + "is Running.");
  27.             }
  28.         }, "T3");
  29.         thread3.start();
  30.         thread2.start();
  31.         thread1.start();
复制代码
CountDownLatch
AQS中的CountDownLatch并发工具类,通过它可以壅闭当前线程,也就是说可以或许实现一个线程大概多个线程的一直等待,直到其他线程执行的操纵完成,使用一个给定的计数器进行初始化,该技术器的操纵是原子操纵,即同时只能有一个线程操纵该计数器,调用该类的await方法的线程会一直壅闭直到其他线程调用该类的countDown方法使当前计数器的值变为0为止,每次调用该类的countDown方法当前计数器的值都会减1,当计数器的值减为0的时间所欲因调用await方法而出处于等待状态的线程就会继续往下执行,这种操纵只能出现一次,由于该类的计数器不能被重置,如果需要一个可以重置的计数次数的版本可以思量使用CyclicBarrier类,CountDownLatch支持给定时间等待,高出一定时间不再等待,使用时只需要在CountDownLatch方法中传入需要等待的时间即可,使用场景:在程序执行需要等待某个条件完成后才能继续执行后续的操纵,典型的应用为并行计算,当某个处理的运算量很大时可以将该运算拆分成多个子任务,等待所有的子任务都完成后,父任务再拿到所有子任务的运算计算效果汇总。
  1. // 创建CountDownLatch对象,用来做线程通信
  2.         CountDownLatch latch = new CountDownLatch(1);
  3.         CountDownLatch latch2 = new CountDownLatch(1);
  4.         CountDownLatch latch3 = new CountDownLatch(1);
  5.         // 创建并启动线程T1
  6.         Thread t1 = new Thread(new MyThread(latch), "T1");
  7.         t1.start();
  8.         // 等待线程T1执行完
  9.         latch.await();
  10.         // 创建并启动线程T2
  11.         Thread t2 = new Thread(new MyThread(latch2), "T2");
  12.         t2.start();
  13.         // 等待线程T2执行完
  14.         latch2.await();
  15.         // 创建并启动线程T3
  16.         Thread t3 = new Thread(new MyThread(latch3), "T3");
  17.         t3.start();
  18.         // 等待线程T3执行完
  19.         latch3.await();
  20.     }
  21. }
  22. class MyThread implements Runnable {
  23.     private CountDownLatch latch;
  24.     public MyThread(CountDownLatch latch) {
  25.         this.latch = latch;
  26.     }
  27.     @Override
  28.     public void run() {
  29.         try {
  30.             // 模拟执行任务
  31.             Thread.sleep(1000);
  32.             System.out.println(Thread.currentThread().getName() + "is Running.");
  33.         } catch (InterruptedException e) {
  34.             e.printStackTrace();
  35.         } finally {
  36.             // 完成一个线程,计数器减1
  37.             latch.countDown();
  38.         }
  39.     }
复制代码
CyclicBarrier
答应一组线程相互等待直到到达某个公共的屏障点,通过它可以完成多个线程之间的相互等待,只有当每个线程都准备就绪后才能各自继续往下执行后面的操纵,与CountDownLatch有相似的地方都是使用计数器实现,当某个线程调用了CyclicBarrier的await方法后,该线程就进入了等待状态,而且计数器执行加1操纵,当计数器的值达到了设置的初始值调用await方法进入等待状态的线程会被叫醒继续执行各自后续的操纵,CyclicBarrier在开释等待线程后可以重复使用,以是,CyclicBarrier又被称为循环屏障,使用场景:CyclicBarrier可以用于多线程计算数据,最后合并计算效果的场景。
  1. // CyclicBarrier,用来做线程通信
  2.         CyclicBarrier barrier = new CyclicBarrier(2);
  3.         // 创建并启动线程T1
  4.         Thread t1 = new Thread(new MyThread(barrier), "T1");
  5.         t1.start();
  6.         // 等待线程T1执行完
  7.         barrier.await();
  8.         // 创建并启动线程T2
  9.         Thread t2 = new Thread(new MyThread(barrier), "T2");
  10.         t2.start();
  11.         // 等待线程T2执行完
  12.         barrier.await();
  13.         // 创建并启动线程T3
  14.         Thread t3 = new Thread(new MyThread(barrier), "T3");
  15.         t3.start();
  16.         // 等待线程T3执行完
  17.         barrier.await();
  18.     }
  19. }
  20. class MyThread implements Runnable {
  21.     private CyclicBarrier barrier;
  22.     public MyThread(CyclicBarrier barrier) {
  23.         this.barrier = barrier;
  24.     }
  25.     @Override
  26.     public void run() {
  27.         try {
  28.             // 模拟执行任务
  29.             Thread.sleep(1000);
  30.             System.out.println(Thread.currentThread().getName() + "is Running.");
  31.         } catch (InterruptedException e) {
  32.             e.printStackTrace();
  33.         } finally {
  34.             // 等待其他线程完成
  35.             try {
  36.                 barrier.await();
  37.             } catch (Exception e) {
  38.                 e.printStackTrace();
  39.             }
  40.         }
  41.     }
复制代码
Semaphore
控制同一时间并发线程的数量,可以或许完成对于信号量的控制,可以控制某个资源同时访问的个数,提供了两个核心方法acquire和release方法,acquire方法表示获取一个答应,如果没有则等待,release方法则是在操纵完成后开释对应的答应,Semaphore维护了当前访问的个数,通过提供同步机制来控制同时访问的个数,Semaphore可以实现有限大小的链表,使用场景:常用于仅能提供有限访问资源的业务场景,比如数据库连接数。业务请求并发太高已经高出了系统并发处理的阈值,对高出上限的请求进行丢弃处理。
  1. // Semaphore,用来做线程通信
  2.         Semaphore semaphore = new Semaphore(1);
  3.         // 创建并启动线程T1
  4.         Thread t1 = new Thread(new MyThread(semaphore), "T1");
  5.         t1.start();
  6.         // 等待线程T1执行完
  7.         semaphore.acquire();
  8.         // 创建并启动线程T2
  9.         Thread t2 = new Thread(new MyThread(semaphore), "T2");
  10.         t2.start();
  11.         // 等待线程T2执行完
  12.         semaphore.acquire();
  13.         // 创建并启动线程T3
  14.         Thread t3 = new Thread(new MyThread(semaphore), "T3");
  15.         t3.start();
  16.         // 等待线程T3执行完
  17.         semaphore.acquire();
  18.     }
  19. }
  20. class MyThread implements Runnable {
  21.     private Semaphore semaphore;
  22.     public MyThread(Semaphore semaphore) {
  23.         this.semaphore = semaphore;
  24.     }
  25.     @Override
  26.     public void run() {
  27.         try {
  28.             // 模拟执行任务
  29.             Thread.sleep(1000);
  30.             System.out.println(Thread.currentThread().getName() + "is Running.");
  31.         } catch (InterruptedException e) {
  32.             e.printStackTrace();
  33.         } finally {
  34.             // 释放许可证,表示完成以一个线程
  35.             semaphore.release();
  36.         }
  37.     }
复制代码
使用线程池
  1.     public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
  2.         // 创建线程池
  3.         ExecutorService executor = Executors.newSingleThreadExecutor();
  4.         // 创建并启动线程T1
  5.         executor.submit(new MyThread("T1"));
  6.         // 创建并启动线程T2
  7.         executor.submit(new MyThread("T2"));
  8.         // 创建并启动线程T3
  9.         executor.submit(new MyThread("T3"));
  10.         // 关闭线程池
  11.         executor.shutdown();
  12.     }
  13. }
  14. class MyThread implements Runnable {
  15.     private String name;
  16.     public MyThread(String name) {
  17.         this.name = name;
  18.     }
  19.     @Override
  20.     public void run() {
  21.         try {
  22.             // 模拟执行任务
  23.             Thread.sleep(1000);
  24.             System.out.println(name + "is Running.");
  25.         } catch (InterruptedException e) {
  26.             e.printStackTrace();
  27.         }
  28.     }
  29. }
复制代码
CompletableFuture
  1. public class TestYang {
  2.     public static void main(String[] args) throws ExecutionException, InterruptedException {
  3.         //创建CompletableFuture对象
  4.         CompletableFuture<Void> future = CompletableFuture.runAsync(new MyThread("T1")).thenRun(new MyThread("T2")).thenRun(new MyThread("T3"));
  5.         future.get();
  6.     }
  7. }
  8.     class MyThread implements Runnable {
  9.         private String name;
  10.         public MyThread(String name) {
  11.             this.name = name;
  12.         }
  13.         @Override
  14.         public void run() {
  15.             try {
  16.                 // 模拟执行任务
  17.                 Thread.sleep(1000);
  18.                 System.out.println(name + "is Running.");
  19.             } catch (InterruptedException e) {
  20.                 e.printStackTrace();
  21.             }
  22.         }
  23. }
复制代码
20、volatile 是怎样保证可见性和有序性的不能保证原子性的

对于volatile变量,当对volatile变量进行写操纵的时间,JVM会向处理器发送一条lock前缀的指令,将这个缓存中
的变量回写到系统主存中。
以是,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被逼迫刷入主存。而其他处理器的缓
存由于服从了缓存同等性协议,也会把这个变量的值从主存加载到本身的缓存中。这就保证了一个volatile在并发
编程中,其值在多个缓存中是可见的。
volatile除了可以保证数据的可见性之外,另有一个强大的功能,那就是他可以克制指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值效果的地方都能得到精确的效果,而不能保证变量的赋
值操纵的次序与程序代码中的执行次序同等。
volatile是通过内存屏障来克制指令重排的,这就保证了代码的程序会严酷按照代码的先后次序执行。
为什么volatile不能保证原子性呢?由于他不是锁,他没做任何可以保证原子性的处理。当然就不能保证原子性了。
我们通过下面的代码即可证实:
  1. public class VolatoleAtomicityDemo {
  2.     public volatile static int inc = 0;
  3.     public void increase() {
  4.         inc++;
  5.     }
  6.     public static void main(String[] args) throws InterruptedException {
  7.         ExecutorService threadPool = Executors.newFixedThreadPool(5);
  8.         VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
  9.         for (int i = 0; i < 5; i++) {
  10.             threadPool.execute(() -> {
  11.                 for (int j = 0; j < 500; j++) {
  12.                     volatoleAtomicityDemo.increase();
  13.                 }
  14.             });
  15.         }
  16.         // 等待1.5秒,保证上面程序执行完成
  17.         Thread.sleep(1500);
  18.         System.out.println(inc);
  19.         threadPool.shutdown();
  20.     }
  21. }
复制代码
正常环境下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出效果都小于 2500。
为什么会出现这种环境呢?不是说好了,volatile 可以保证变量的可见性嘛!
也就是说,如果 volatile 能保证 inc++ 操纵的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立刻看到修改后的值。5 个线程分别进行了 500 次操纵,那么最终 inc 的值应该是 5*500=2500。很多人会误以为自增操纵 inc++ 是原子性的,现实上,inc++ 着实是一个复合操纵,包括三步:
volatile 是无法保证这三个操纵是具有原子性的,有大概导致下面这种环境出现:
这也就导致两个线程分别对 inc 进行了一次自增操纵后,inc 现实上只增长了 1。着实,如果想要保证上面的代码运行精确也非常简朴,使用 synchronized 、Lock大概AtomicInteger都可以。
使用 synchronized 改进
  1. public synchronized void increase() {
  2.     inc++;
  3. }
复制代码
使用 AtomicInteger 改进
  1. public AtomicInteger inc = new AtomicInteger();
  2. public void increase() {
  3.     inc.getAndIncrement();
  4. }
复制代码
使用 ReentrantLock 改进
  1. Lock lock = new ReentrantLock();
  2. public void increase() {
  3.     lock.lock();
  4.     try {
  5.         inc++;
  6.     } finally {
  7.         lock.unlock();
  8.     }
  9. }
复制代码
21、synchronized 和 volatile 有什么区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

22、什么是死锁,怎样办理

线程死锁(Thread Deadlock) 是多线程编程中的一种常见问题,指的是两个或多个线程在执行过程中,由于争取资源而造成的一种互相等待的征象,导致这些线程都无法继续执行下去。
产生死锁的四个必要条件
  1. public class DeadlockExample {
  2.     private static final Object resource1 = new Object();
  3.     private static final Object resource2 = new Object();
  4.     public static void main(String[] args) {
  5.         Thread thread1 = new Thread(() -> {
  6.             synchronized (resource1) {
  7.                 System.out.println("Thread 1: Holding resource 1...");
  8.                 try { Thread.sleep(100); } catch (InterruptedException e) {}
  9.                 System.out.println("Thread 1: Waiting for resource 2...");
  10.                 synchronized (resource2) {
  11.                     System.out.println("Thread 1: Holding resource 1 and 2...");
  12.                 }
  13.             }
  14.         });
  15.         Thread thread2 = new Thread(() -> {
  16.             synchronized (resource2) {
  17.                 System.out.println("Thread 2: Holding resource 2...");
  18.                 try { Thread.sleep(100); } catch (InterruptedException e) {}
  19.                 System.out.println("Thread 2: Waiting for resource 1...");
  20.                 synchronized (resource1) {
  21.                     System.out.println("Thread 2: Holding resource 1 and 2...");
  22.                 }
  23.             }
  24.         });
  25.         thread1.start();
  26.         thread2.start();
  27.     }
  28. }
复制代码
怎样解除死锁

数据库死锁的发生
在数据库中,如果有多个事务并发执行,也是大概发生死锁的。当事务1持有资源八的锁,但是实行获取资源B的
锁,而事务2持有资源B的锁,实行获取资源A的锁的时间,这时间就会发生死锁的环境发生死锁时,会发生如下异常:
  1. Error updating database.Cause:ERR-CODE:[TDDL-4614][ERR_EXECUTE_ON_MYSQL]
  2. Deadlock found when trying to get lock;
复制代码
23、先容一下 Atomic 原子类

Atomic 原子类 是 Java 并发包(java.util.concurrent.atomic)中提供的一组类,用于在多线程环境下实现无锁的线程安全操纵。它们通过硬件级别的原子操纵(如 CAS,Compare-And-Swap)来保证操纵的原子性,克制了使用锁带来的性能开销。



Atomic 原子类 通过 CAS 操纵实现了无锁的线程安全操纵,实用于高性能并发场景。常用的 Atomic 原子类包括 AtomicInteger、AtomicLong、AtomicReference 等。在现实开辟中,Atomic 原子类可以替换锁,简化并发编程并提升性能。
24、什么是CAS,存在什么问题

CAS(Compare-And-Swap) 是一种用于实现多线程同步的原子操纵,它是无锁编程的核心技术之一。CAS 操纵通过硬件指令直接支持,可以或许在不需要锁的环境下实现线程安全。

  1. boolean compareAndSwap(V, A, B) {
  2.     if (V == A) {
  3.         V = B;
  4.         return true;
  5.     }
  6.     return false;
  7. }
复制代码

办理方法:
  1. 使用版本号或时间戳标记变量的变化。
  2. 示例:Java 中的 AtomicStampedReference 和 AtomicMarkableReference。
复制代码

办理方法:
  1. 限制重试次数,或结合退避算法(如指数退避)。
复制代码

办理方法:
  1. 使用锁或其他同步机制。
复制代码
Java中CAS的使用
Java中大量使用的CAS,比如java.util.concurrent.atomic包下有很多的原子类AtomicInteger、AtomicBoolean…这些类提供对int、boolean等范例的原子操纵,而底层就是通过CAS机制实现的。
比如AtomicInteger类有一个实例方法,叫做incrementAndGet,这个方法就是将AtomicInteger对象记录的值+1并返回,与i++类似。但是这是一个原子操纵,不会像i++一样,存在线程不同等问题,由于i++不是原子操纵。比如如下代码,最终一定可以或许保证num的值为200:
  1. // 声明一个AtomicInteger对象
  2. AtomicInteger num = new AtomicInteger(0);
  3. // 线程1
  4. new Thread(() -> {
  5.     for (int i = 0; i < 100; i++) {
  6.         // num++
  7.         num.incrementAndGet();
  8.     }
  9. }).start();
  10. // 线程2
  11. new Thread(() -> {
  12.     for (int i = 0; i < 100; i++) {
  13.         // num++
  14.         num.incrementAndGet();
  15.     }
  16. }).start();
  17. Thread.sleep(1000);
  18. System.out.println(num);
复制代码
25、怎样理解AQS

AQS就是AbstractQueuedSynchronizer抽象类,AQS着实就是JUC包下的一个基类,JUC下的很多内容都是基于AQS实现了部分功能,比如ReentrantLock,ThreadPoolExecutor,壅闭队列,CountDownLatch,Semaphore,CyclicBarrier等等都是基于AQS实现。
(1)起首AQS中提供了一个由volatile修饰,并且采用CAS方式修改的int范例的state变量。
  1. public abstract class AbstractQueuedSynchronizer
  2.     extends AbstractOwnableSynchronizer
  3.         implements java.io.Serializable {
  4.     // 同步state成员变量,0表示无人占用锁,大于1表示锁被占用需要等待;
  5.     private volatile int state;
  6.    
  7.     /*CLH队列
  8.      * <pre>
  9.      *      +------+  prev +-----+       +-----+
  10.      * head |      | <---- |     | <---- |     |  tail
  11.      *      +------+       +-----+       +-----+
  12.      * </pre>
  13.      */
  14.     // 通过state自旋判断是否阻塞,阻塞的线程放入队列,尾部入队,头部出队
  15.     static final class Node{}
  16.     private transient volatile Node head;
  17.     private transient volatile Node tail;
  18. }
复制代码
(2)其次AQS中维护了一个双向链表,有head,有tail,并且每个节点都是Node对象
  1. static final class Node {
  2.                 // 表示线程以共享的模式等待锁
  3.         static final Node SHARED = new Node();
  4.         // 表示线程以独占的方式等待锁
  5.         static final Node EXCLUSIVE = null;
  6.         //表示线程获取锁的请求已经取消了
  7.         static final int CANCELLED =  1;
  8.         //表示线程准备解锁
  9.         static final int SIGNAL    = -1;
  10.         // 表示节点在等待队列红,节点线等待唤醒
  11.         static final int CONDITION = -2;
  12.         // 表当前节点线程处于共享模式,锁可以传递下去
  13.         static final int PROPAGATE = -3;
  14.                    // 表示节点在队列中的状态
  15.              volatile int waitStatus;
  16.             volatile Node prev;// 前序指针
  17.             volatile Node next;// 后序指针
  18.             volatile Thread thread; // 当前节点的线程
  19.             Node nextWaiter;// 指向下一个处于Condition状态的节点
  20.             final Node predecessor();//一个方法,返回前序节点prev,没有的话抛出NPE(空指针异常)
  21. }
复制代码
AQS的核心原理

当多线程访问共享资源(state)时,流程如下:

AQS叫醒节点为何从后往前找
node节点在插入整个AQS队列当中时是先把当前节点的上一个指针指向前面的节点,再把tail指向本身,这个时间会有一个CPU调度问题,如果这个时间我卡在这个位置,那么从前今后找就会造成节点丢失,就会出现找到空的节点的问题无法实现有用的线程叫醒导致出现死锁的问题
aqs中的取消节点的方法,cancelAcquire也是先去调整上一个指针的指向,next指针后续才动,以是无论是我们节点插入的过程还是某一个节点取消个更改指针的过程,都是先动上一个指针再动next的,以是prex这个节点指向相对来说优先级更高大概时效性更好。
总结由于从前今后极大大概错过某一个节点,从而造成某一个node在那边被挂起了,但是你之前的线程已经开释资源了并没有被叫醒,造成锁饥饿问题,总的来说AQS在叫醒节点时间从后往前找只要是为了找一个有用且可以被叫醒的接点,来保证并发程序的效率。
26、什么是 Java 内存模型

Java内存模型规定了所有的变量都存储在主内存中,每条线程另有本身的工作内存,线程的工作内存中生存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操纵都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要本身的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了怎样做数据同步以及什么时间做数据同步。

以是,再来总结下,JMM是一种规范,目的是办理由于多线程通过共享内存进行通信时,存在的本地内存数据不同等、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
Java内存模型的实现
在开辟多线程的代码的时间,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存同等性等问题。以是,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开辟者直接使用。
并发编程要办理原子性、有序性和同等性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit,在Java中可以使用synchronized来保证方法和代码块内的操纵是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存革新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立刻同步到主内存,被其修饰的变量在每次是用之前都从主内存革新。因此,可以使用volatile来保证多线程操纵时变量的可见性。
除了volatile,Java中的synchronized关键字也可以实现可见性。只不外实现方式不同。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操纵的有序性。实现方式有所区别:
volatile关键字会克制指令重排。synchronized关键字保证同一时刻只答应一条线程操纵。
27、三个线程分别次序打印 0-100

  1. public class H {
  2.     private static volatile int count = 0;
  3.     public void yang() {
  4.         Runnable a = () -> {
  5.             while (count <= 100) {
  6.                 synchronized (this) {
  7.                     String s = Thread.currentThread().getName().split("-")[1];
  8.                     try {
  9.                         while (count % 3 != Integer.parseInt(s)) {
  10.                             this.wait();
  11.                         }
  12.                         if (count <= 100) {
  13.                             System.out.println(Thread.currentThread().getName() + ":" + count++);
  14.                         }
  15.                         this.notifyAll();
  16.                     } catch (Exception e) {
  17.                         e.printStackTrace();
  18.                     }
  19.                 }
  20.             }
  21.         };
  22.         Thread thread0 = new Thread(a);
  23.         Thread thread1 = new Thread(a);
  24.         Thread thread2 = new Thread(a);
  25.         thread0.start();
  26.         thread1.start();
  27.         thread2.start();
  28.     }
  29.     public static void main(String[] args) {
  30.         H h = new H();
  31.         h.yang();
  32.     }
  33. }
复制代码
28、JMM(Java Memory Model)

JMM 是 Java 语言规范中定义的一种抽象的内存模型,它定义了多线程环境下,线程怎样与主内存和工作内存交互,以及怎样保证多线程程序的可见性、有序性和原子性。
JMM 关注的是多线程并发编程中的内存同等性问题,而不是内存的物理划分。
JMM 的核心概念
所有线程共享的内存地区,存储共享变量(如实例变量、静态变量)。
每个线程私有的内存地区,存储线程对共享变量的副本。线程对变量的所有操纵(读/写)都发生在工作内存中。

JMM 办理的问题
(1)可见性(Visibility)
一个线程对共享变量的修改,其他线程是否可以或许立刻看到。通过 volatile 关键字、synchronized 锁等机制保证可见性。
(2)有序性(Ordering)
程序执行的次序是否与代码编写的次序同等。通过 happens-before 规则和内存屏障(Memory Barrier)保证有序性。
(3)原子性(Atomicity)
一个操纵是否是不可分割的。通过 synchronized 锁、java.util.concurrent.atomic 包中的原子类等机制保证原子性。
JMM 的现实应用
(1)volatile 关键字

(2)synchronized 关键字

(3)final 关键字

(4)java.util.concurrent 包


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 qidao123.com技术社区-IT企服评测·应用市场 (https://dis.qidao123.com/) Powered by Discuz! X3.4