一文搞定并发面试题
1、object 的 wait()和notify() 方法
下图为线程状态的图:
object 对象中的 wait()和notify()是用来实现实现等待 / 通知模式。其中等待状态和阻塞状态是不同的。等待状态的线程可以通过notify() 方法唤醒并继续执行,而阻塞状态的线程则是等待获取新的锁。
-
调用 wait()方法后,当前线程会进入等待状态,直到其他线程调用notify()或notifyall() 来唤醒。
-
调用 notify() 方法后,可以唤醒正在等待的单一线程。
2、并发特性 - 原子性、有序性、可见性
-
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
-
可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
-
有序性:即程序执行的顺序按照代码的先后顺序执行,不进行指令重排列。
3、synchronized 实现原理?
synchronized 可以保证方法或者代码块在运行时,同一时刻只有一个进程可以访问,同时它还可以保证共享变量的内存可见性。
java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础:
-
普通同步方法,锁是当前实例对象
-
静态同步方法,锁是当前类的 class 对象
-
同步方法块,锁是括号里面的对象
同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,jvm 需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个 monitor 与之相关联,当且一个 monitor 被持有之后,他将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的 monitor 所有权,即尝试获取对象的锁。
同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在 vm 字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在 class 文件的方法表中将该方法的access_flags字段中的synchronized 标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 class 在 jvm 的内部对象表示 klass 作为锁对象。
synchronized 是重量级锁,在 jdk1.6 中进行优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
相关文章参考
4、volatile 的实现原理?
volatile 是轻量级的锁,它不会引起线程上下文的切换和调度。
-
volatile可见性:对一个volatile 的读,总可以看到对这个变量最终的写。
-
volatile 原子性:volatile对单个读 / 写具有原子性(32 位 long、double),但是复合操作除外,例如i++ 。
-
jvm 底层采用“内存屏障”来实现 volatile 语义,防止指令重排序。
volatile 经常用于两个两个场景:状态标记变量、double check 。
5、java 内存模型(jmm)
jmm 规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。
-
一方面,要为程序员提供足够强的内存可见性保证。
-
另一方面,对编译器和处理器的限制要尽可能地放松。jmm 对程序员屏蔽了 cpu 以及 os 内存的使用问题,能够使程序在不同的 cpu 和 os 内存上都能够达到预期的效果。
java 采用内存共享的模式来实现线程之间的通信。编译器和处理器可以对程序进行重排序优化处理,但是需要遵守一些规则,不能随意重排序。
在并发编程模式中,势必会遇到上面三个概念:
-
原子性:一个操作或者多个操作要么全部执行要么全部不执行。
-
可见性:当多个线程同时访问一个共享变量时,如果其中某个线程更改了该共享变量,其他线程应该可以立刻看到这个改变。
-
有序性:程序的执行要按照代码的先后顺序执行。
通过 volatile、synchronized、final、concurrent 包等 实现。
6、有关队列 aqs 队列同步器
aqs 是构建锁或者其他同步组件的基础框架(如 reentrantlock、reentrantreadwritelock、semaphore 等), 包含了实现同步器的细节(获取同步状态、fifo 同步队列)。aqs 的主要使用方式是继承,子类通过继承同步器,并实现它的抽象方法来管理同步状态。
维护一个同步状态 state。当 state > 0时,表示已经获取了锁;当state = 0 时,表示释放了锁。
aqs 通过内置的 fifo 同步队列来完成资源获取线程的排队工作:
-
如果当前线程获取同步状态失败(锁)时,aqs 则会将当前线程以及等待状态等信息构造成一个节点(node)并将其加入同步队列,同时会阻塞当前线程
-
当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
aqs 内部维护的是** clh 双向同步队列**
7、锁的特性
可重入锁:指的是在一个线程中可以多次获取同一把锁。 reentrantlock 和 synchronized 都是可重入锁。
可中断锁:顾名思义,就是可以相应中断的锁。synchronized 就不是可中断锁,而 lock 是可中断锁。
公平锁:即尽量以请求锁的顺序来获取锁。synchronized 是非公平锁,reentrantlock 和 reentrantreadwritelock,它默认情况下是非公平锁,但是可以设置为公平锁。
相关文章参考
8、reentrantlock 锁
reentrantlock,可重入锁,是一种递归无阻塞的同步机制。它可以等同于 synchronized的使用,但是 reentrantlock 提供了比synchronized 更强大、灵活的锁机制,可以减少死锁发生的概率。
-
reentrantlock 实现 lock 接口,基于内部的 sync 实现。
-
sync 实现 aqs ,提供了 fairsync 和 nonfairsync 两种实现。
condition
condition 和 lock 一起使用以实现等待/通知模式,通过 await()和singnal() 来阻塞和唤醒线程。
condition 是一种广义上的条件队列。他为线程提供了一种更为灵活的等待 / 通知模式,线程在调用 await 方法后执行挂起操作,直到线程等待的某个条件为真时才会被唤醒。condition 必须要配合 lock 一起使用,因为对共享状态变量的访问发生在多线程环境下。一个 condition 的实例必须与一个 lock 绑定,因此 condition 一般都是作为 lock 的内部实现。
9、reentrantreadwritelock
读写锁维护着一对锁,一个读锁和一个写锁。通过分离读锁和写锁,使得并发性比一般的排他锁有了较大的提升:
-
在同一时间,可以允许多个读线程同时访问。
-
但是,在写线程访问时,所有读线程和写线程都会被阻塞。
读写锁的主要特性:
-
公平性:支持公平性和非公平性。
-
重入性:支持重入。读写锁最多支持 65535 个递归写入锁和 65535 个递归读取锁。
-
锁降级:遵循获取写锁,再获取读锁,最后释放写锁的次序,如此写锁能够降级成为读锁。
reentrantreadwritelock 实现 readwritelock 接口,可重入的读写锁实现类。
在同步状态上,为了表示两把锁,将一个 32 位整型分为高 16 位和低 16 位,分别表示读和写的状态
10、synchronized 和 lock 的区别
-
lock 是一个接口,而 synchronized 是 java 中的关键字,synchronized 是内置的语言实现;
-
synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 lock 在发生异常时,如果没有主动通过 unlock() 去释放锁,则很可能造成死锁现象,因此使用 lock 时需要在 finally 块中释放锁;
-
lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,- 等待的线程会一直等待下去,不能够响应中断;
-
通过 lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
-
lock 可以提高多个线程进行读操作的效率。
更深的:
-
与 synchronized 相比,reentrantlock 提供了更多,更加全面的功能,具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。
-
reentrantlock 还提供了条件 condition ,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,reentrantlock 更加适合(以后会阐述 condition)。
-
reentrantlock 提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而 synchronized则一旦进入锁请求要么成功要么阻塞,所以相比synchronized 而言,reentrantlock 会不容易产生死锁些。
-
reentrantlock 支持更加灵活的同步代码块,但是使用 synchronized时,只能在同一个synchronized块结构中获取和释放。注意,reentrantlock 的锁释放一定要在finally 中处理,否则可能会产生严重的后果。
-
reentrantlock 支持中断处理,且性能较 synchronized 会好些。
11、java 中线程同步的方式
-
sychronized 同步方法或代码块
-
volatile
-
lock
-
threadlocal
-
阻塞队列(linkedblockingqueue)
-
使用原子变量(java.util.concurrent.atomic)
-
变量的不可变性
12、cas 是一种什么样的同步机制?多线程下为什么不使用 int 而使用 atomicinteger?
compare and swap,比较交换。可以看到 synchronized 可以保证代码块原子性,很多时候会引起性能问题,volatile也是个不错的选择,但是volatile 不能保证原子性,只能在某些场合下使用。所以可以通过 cas 来进行同步,保证原子性。
我们在读 concurrent 包下的类的源码时,发现无论是 reentrantlock 内部的 aqs,还是各种 atomic 开头的原子类,内部都应用到了 cas。
在 cas 中有三个参数:内存值 v、旧的预期值 a、要更新的值 b ,当且仅当内存值 v 的值等于旧的预期值 a 时,才会将内存值 v 的值修改为 b,否则什么都不干。其伪代码如下:
if (this.value == a) { this.value = b return true; } else { return false; }
cas 可以保证一次的读-改-写操作是原子操作。
在多线程环境下,int 类型的自增操作不是原子的,线程不安全,可以使用 atomicinteger 代替。
// atomicinteger.java private static final unsafe unsafe = unsafe.getunsafe(); private static final long valueoffset; static { try { valueoffset = unsafe.objectfieldoffset (atomicinteger.class.getdeclaredfield("value")); } catch (exception ex) { throw new error(ex); } } private volatile int value;
-
unsafe 是 cas 的核心类,java 无法直接访问底层操作系统,而是通过本地 native` 方法来访问。不过尽管如此,jvm 还是开了一个后门:unsafe ,它提供了硬件级别的原子操作。
-
valueoffset 为变量值在内存中的偏移地址,unsafe 就是通过偏移地址来得到数据的原值的。
-
value当前值,使用volatile 修饰,保证多线程环境下看见的是同一个。
// atomicinteger.java public final int addandget(int delta) { return unsafe.getandaddint(this, valueoffset, delta) + delta; } // unsafe.java // compareandswapint(var1, var2, var5, var5 + var4)其实换成 compareandswapint(obj, offset, expect, update)比较清楚,意思就是如果 obj 内的 value 和 expect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update,如果这一步的 cas 没有成功,那就采用自旋的方式继续进行 cas 操作,取出乍一看这也是两个步骤了啊,其实在 jni 里是借助于一个 cpu 指令完成的。所以还是原子操作。 public final int getandaddint(object var1, long var2, int var4) { int var5; do { var5 = this.getintvolatile(var1, var2); } while(!this.compareandswapint(var1, var2, var5, var5 + var4)); return var5; } // 该方法为本地方法,有四个参数,分别代表:对象、对象的地址、预期值、修改值 public final native boolean compareandswapint(object var1, long var2, int var4, int var5);
13、hashmap 是不是线程安全?如何体现?如何变得安全?
由于添加元素到 map 中去时,数据量大产生扩容操作,多线程会导致 hashmap 的 node 链表形成环状的数据结构产生死循环。所以 hashmap 是线程不安全的。
如何变得安全:
-
hashtable:通过 synchronized 来保证线程安全的,独占锁,悲观策略。吞吐量较低,性能较为低下
-
synchronizedhashmap :通过 collections.synchronizedmap() 方法对 hashmap 进行包装,返回一个 synchronizedhashmap 对象,在源码中 synchronizedhashmap 也是用过 synchronized 来保证线程安全的。但是实现方式和 hashtable 略有不同(前者是 synchronized 方法,后者是通过 synchronized 对互斥变量加锁实现)
-
concurrenthashmap:juc 中的线程安全容器,高效并发。concurrenthashmap 的 key、value 都不允许为 null。
14、concurrenthashmap 的实现方式?
concurrenthashmap 的实现方式和 hashtable 不同,不采用独占锁的形式,更高效,其中在 jdk1.7 和 jdk1.8 中实现的方式也略有不同。
jdk1.7 中采用分段锁和 hashentry 使锁更加细化。concurrenthashmap 采用了分段锁技术,其中 segment 继承于 reentrantlock。不会像 hashtable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 concurrenthashmap 支持 currencylevel (segment 数组数量)的线程并发。
jdk1.8 利用 cas+synchronized 来保证并发更新的安全,当然底层采用数组+链表+红黑树的存储结构。
-
table 中存放 node 节点数据,默认 node 数据大小为 16,扩容大小总是 2^n。
-
为了保证可见性,node 节点中的 val 和 next 节点都用 volatile 修饰。
-
当链表长度大于 8 时,会转换成红黑树,节点会被包装成 treenode放在treebin 中。
-
put():1. 计算键所对应的 hash 值;2. 如果哈希表还未初始化,调用 inittable() 初始化,否则在 table 中找到 index 位置,并通过 cas 添加节点。如果链表节点数目超过 8,则将链表转换为红黑树。如果节点总数超过,则进行扩容操作。
-
get():无需加锁,直接根据 key 的 hash 值遍历 node。
15、countdownlatch 和 cyclicbarrier 的区别? 并发工具类
cyclicbarrier 它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 cyclicbarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 ( cyclic ) 的 屏障 ( barrier ) 。
每个线程调用 #await() 方法,告诉 cyclicbarrier 我已经到达了屏障,然后当前线程被阻塞。当所有线程都到达了屏障,结束阻塞,所有线程可继续执行后续逻辑。
countdownlatch 能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为 0 时,表示所有的线程都已经完成了任务,然后在 countdownlatch 上等待的线程就可以恢复执行任务。
两者区别:
-
countdownlatch 的作用是允许 1 或 n 个线程等待其他线程完成执行;而 cyclicbarrier 则是允许 n 个线程相互等待。
-
countdownlatch 的计数器无法被重置;cyclicbarrier 的计数器可以被重置后使用,因此它被称为是循环的 barrier 。
semaphore 是一个控制访问多个共享资源的计数器,和 countdownlatch 一样,其本质上是一个“共享锁”。一个计数信号量。从概念上讲,信号量维护了一个许可集。
-
如有必要,在许可可用前会阻塞每一个 acquire,然后再获取该许可。
-
每个 release 添加一个许可,从而可能释放一个正在阻塞的获取者。
16、怎么控制线程,尽可能减少上下文切换?
减少上下文切换的方法有无锁并发编程、cas算法、使用最少线程和使用协程。
-
无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以使用一些方法来避免使用锁。如将数据的id按照hash算法取模分段,不同的线程处理不同段的数据。
-
cas算法。java的atomic包使用cas算法来更新数据,而不需要加锁。
-
使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
-
协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
17、什么是乐观锁和悲观锁?
像 synchronized这种独占锁属于悲观锁,它是在假设一定会发生冲突的,那么加锁恰好有用,除此之外,还有乐观锁,乐观锁的含义就是假设没有发生冲突,那么我正好可以进行某项操作,如果要是发生冲突呢,那我就重试直到成功,乐观锁最常见的就是cas。
18、阻塞队列
阻塞队列实现了 blockingqueue 接口,并且有多组处理方法。
抛出异常:add(e) 、remove()、element()
返回特殊值:offer(e) 、pool()、peek()
阻塞:put(e) 、take()
jdk 8 中提供了七个阻塞队列可供使用:
-
arrayblockingqueue :一个由数组结构组成的有界阻塞队列。
-
linkedblockingqueue :一个由链表结构组成的*阻塞队列。
-
priorityblockingqueue :一个支持优先级排序的*阻塞队列。
-
delayqueue:一个使用优先级队列实现的*阻塞队列。
-
synchronousqueue:一个不存储元素的阻塞队列。
-
linkedtransferqueue:一个由链表结构组成的*阻塞队列。
-
linkedblockingdeque:一个由链表结构组成的双向阻塞队列。
arrayblockingqueue,一个由数组实现的有界阻塞队列。该队列采用 fifo 的原则对元素进行排序添加的。内部使用可重入锁 reentrantlock + condition 来完成多线程环境的并发操作。
19、线程池
线程池有五种状态:running, shutdown, stop, tidying, terminated。
-
running:接收并处理任务。
-
shutdown:不接收但处理现有任务。
-
stop:不接收也不处理任务,同时终端当前处理的任务。
-
tidying:所有任务终止,线程池会变为 tidying 状态。当线程池变为 tidying 状态时,会执行钩子函数 terminated()。
-
terminated:线程池彻底终止的状态。
内部变量** ctl **定义为 atomicinteger ,记录了“线程池中的任务数量”和“线程池的状态”两个信息。共 32 位,其中高 3 位表示”线程池状态”,低 29 位表示”线程池中的任务数量”。
线程池创建参数
corepoolsize
线程池中核心线程的数量。当提交一个任务时,线程池会新建一个线程来执行任务,直到当前线程数等于 corepoolsize。如果调用了线程池的 prestartallcorethreads() 方法,线程池会提前创建并启动所有基本线程。
maximumpoolsize
线程池中允许的最大线程数。线程池的阻塞队列满了之后,如果还有任务提交,如果当前的线程数小于 maximumpoolsize,则会新建线程来执行任务。注意,如果使用的是*队列,该参数也就没有什么效果了。
keepalivetime
线程空闲的时间。线程的创建和销毁是需要代价的。线程执行完任务后不会立即销毁,而是继续存活一段时间:keepalivetime。默认情况下,该参数只有在线程数大于 corepoolsize 时才会生效。
unit
keepalivetime 的单位。timeunit
workqueue
用来保存等待执行的任务的阻塞队列,等待的任务必须实现 runnable 接口。我们可以选择如下几种:
-
arrayblockingqueue:基于数组结构的有界阻塞队列,fifo。
-
linkedblockingqueue:基于链表结构的有界阻塞队列,fifo。
-
synchronousqueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作,反之亦然。
-
priorityblockingqueue:具有优先界别的阻塞队列。
threadfactory
用于设置创建线程的工厂。该对象可以通过 executors.defaultthreadfactory()。他是通过 newthread() 方法提供创建线程的功能,newthread() 方法创建的线程都是“非守护线程”而且“线程优先级都是 thread.norm_priority”。
handler
rejectedexecutionhandler,线程池的拒绝策略。所谓拒绝策略,是指将任务添加到线程池中时,线程池拒绝该任务所采取的相应策略。当向线程池中提交任务时,如果此时线程池中的线程已经饱和了,而且阻塞队列也已经满了,则线程池会选择一种拒绝策略来处理该任务。
线程池提供了四种拒绝策略:
-
abortpolicy:直接抛出异常,默认策略;
-
callerrunspolicy:用调用者所在的线程来执行任务;
-
discardoldestpolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
-
discardpolicy:直接丢弃任务;
当然我们也可以实现自己的拒绝策略,例如记录日志等等,实现 rejectedexecutionhandler 接口即可。
当添加新的任务到线程池时:
-
线程数量未达到 corepoolsize,则新建一个线程(核心线程)执行任务
-
线程数量达到了 corepoolsize,则将任务移入队列等待
-
队列已满,新建线程(非核心线程)执行任务
-
队列已满,总线程数又达到了 maximumpoolsize,就会由 handler 的拒绝策略来处理
线程池可通过 executor 框架来进行创建:
fixedthreadpool
public static executorservice newfixedthreadpool(int nthreads) { return new threadpoolexecutor(nthreads, nthreads, 0l, timeunit.milliseconds, new linkedblockingqueue<runnable>()); }
corepoolsize 和 maximumpoolsize 都设置为创建 fixedthreadpool 时指定的参数 nthreads,意味着当线程池满时且阻塞队列也已经满时,如果继续提交任务,则会直接走拒绝策略,该线程池不会再新建线程来执行任务,而是直接走拒绝策略。fixedthreadpool 使用的是默认的拒绝策略,即 abortpolicy,则直接抛出异常。
但是 workqueue 使用了*的 linkedblockingqueue, 那么当任务数量超过 corepoolsize 后,全都会添加到队列中而不执行拒绝策略。
singlethreadexecutor
public static executorservice newsinglethreadexecutor() { return new finalizabledelegatedexecutorservice (new threadpoolexecutor(1, 1, 0l, timeunit.milliseconds, new linkedblockingqueue<runnable>())); }
作为单一 worker 线程的线程池,singlethreadexecutor 把 corepool 和 maximumpoolsize 均被设置为 1,和 fixedthreadpool 一样使用的是*队列 linkedblockingqueue, 所以带来的影响和 fixedthreadpool 一样。
cachedthreadpool
cachedthreadpool是一个会根据需要创建新线程的线程池 ,他定义如下:
public static executorservice newcachedthreadpool() { return new threadpoolexecutor(0, integer.max_value, 60l, timeunit.seconds, new synchronousqueue<runnable>()); }
这个线程池,当任务提交是就会创建线程去执行,执行完成后线程会空闲60s,之后就会销毁。但是如果主线程提交任务的速度远远大于 cachedthreadpool 的处理速度,则 cachedthreadpool 会不断地创建新线程来执行任务,这样有可能会导致系统耗尽 cpu 和内存资源,所以在使用该线程池是,一定要注意控制并发的任务数,否则创建大量的线程可能导致严重的性能问题。
20、为什么要使用线程池?
-
创建/销毁线程伴随着系统开销,过于频繁的创建/销毁线程,会很大程度上影响处理效率。线程池缓存线程,可用已有的闲置线程来执行新任务(keepalivetime)
-
线程并发数量过多,抢占系统资源从而导致阻塞。运用线程池能有效的控制线程最大并发数,避免以上的问题。
-
对线程进行一些简单的管理(延时执行、定时循环执行的策略等)
21、生产者消费者问题
实例代码用 object 的 wait()和notify() 实现,也可用 reentrantlock 和 condition 来完成。或者直接使用阻塞队列。
public class producerconsumer { public static void main(string[] args) { producerconsumer main = new producerconsumer(); queue<integer> buffer = new linkedlist<>(); int maxsize = 5; new thread(main.new producer(buffer, maxsize), "producer1").start(); new thread(main.new consumer(buffer, maxsize), "comsumer1").start(); new thread(main.new consumer(buffer, maxsize), "comsumer2").start(); } class producer implements runnable { private queue<integer> queue; private int maxsize; producer(queue<integer> queue, int maxsize) { this.queue = queue; this.maxsize = maxsize; } @override public void run() { while (true) { synchronized (queue) { while (queue.size() == maxsize) { try { system.out.println("queue is full"); queue.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } try { thread.sleep(1000); } catch (interruptedexception e) { e.printstacktrace(); } random random = new random(); int i = random.nextint(); system.out.println(thread.currentthread().getname() + " producing value : " + i); queue.add(i); queue.notifyall(); } } } } class consumer implements runnable { private queue<integer> queue; private int maxsize; public consumer(queue<integer> queue, int maxsize) { super(); this.queue = queue; this.maxsize = maxsize; } @override public void run() { while (true) { synchronized (queue) { while (queue.isempty()) { try { system.out.println("queue is empty"); queue.wait(); } catch (exception ex) { ex.printstacktrace(); } } try { thread.sleep(1000); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println(thread.currentthread().getname() + " consuming value : " + queue.remove()); queue.notifyall(); } } } } }
免费java资料领取,涵盖了java、redis、mongodb、mysql、zookeeper、spring cloud、dubbo/kafka、hadoop、hbase、flink等高并发分布式、大数据、机器学习等技术。
上一篇: 张绣赢过曹操三次,为什么还没什么名气?
下一篇: 煎饼果子最早起源于哪里