夯实Java基础系列17:一文搞懂Java多线程使用方式、实现原理以及常见面试题
本系列文章将整理到我在github上的《java面试指南》仓库,更多精彩内容请到我的仓库里查看
https://github.com/h2pl/java-tutorial
喜欢的话麻烦点下star哈
文章首发于我的个人博客:
www.how2playlife.com
java中的线程
java之父对线程的定义是:
线程是一个独立执行的调用序列,同一个进程的线程在同一时刻共享一些系统资源(比如文件句柄等)也能访问同一个进程所创建的对象资源(内存资源)。java.lang.thread对象负责统计和控制这种行为。
每个程序都至少拥有一个线程-即作为java虚拟机(jvm)启动参数运行在主类main方法的线程。在java虚拟机初始化过程中也可能启动其他的后台线程。这种线程的数目和种类因jvm的实现而异。然而所有用户级线程都是显式被构造并在主线程或者是其他用户线程中被启动。
本文主要讲了java中多线程的使用方法、线程同步、线程数据传递、线程状态及相应的一些线程函数用法、概述等。在这之前,首先让我们来了解下在操作系统中进程和线程的区别: 进程:每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含1--n个线程。(进程是资源分配的最小单位) 线程:同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(pc),线程切换开销小。(线程是cpu调度的最小单位) 线程和进程一样分为五个阶段:创建、就绪、运行、阻塞、终止。 多进程是指操作系统能同时运行多个任务(程序)。 多线程是指在同一程序中有多个顺序流在执行。 在java中要想实现多线程,有两种手段,一种是继续thread类,另外一种是实现runable接口.(其实准确来讲,应该有三种,还有一种是实现callable接口,并与future、线程池结合使用
java线程状态机
java 给多线程编程提供了内置的支持。 一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
多线程是多任务的一种特别的形式,但多线程使用了更小的资源开销。
这里定义和线程相关的另一个术语 - 进程:一个进程包括由操作系统分配的内存空间,包含一个或多个线程。一个线程不能独立的存在,它必须是进程的一部分。一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。
多线程能满足程序员编写高效率的程序来达到充分利用 cpu 的目的。
一个线程的生命周期
线程是一个动态执行的过程,它也有一个从产生到死亡的过程。
下图显示了一个线程完整的生命周期。
-
新建状态:
使用 new 关键字和 thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。
-
就绪状态:
当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待jvm里线程调度器的调度。
-
运行状态:
如果就绪状态的线程获取 cpu 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。
-
阻塞状态:
如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:
等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。
同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
其他阻塞:通过调用线程的 sleep() 或 join() 发出了 i/o 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 i/o 处理完毕,线程重新转入就绪状态。
-
死亡状态:
一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。
java多线程实战
多线程的实现
public class 多线程实例 {
//继承thread @test public void test1() { class a extends thread { @override public void run() { system.out.println("a run"); } } a a = new a(); a.start(); } //实现runnable @test public void test2() { class b implements runnable { @override public void run() { system.out.println("b run"); } } b b = new b(); //runable实现类需要由thread类包装后才能执行 new thread(b).start(); } //有返回值的线程 @test public void test3() { callable callable = new callable() { int sum = 0; @override public object call() throws exception { for (int i = 0;i < 5;i ++) { sum += i; } return sum; } }; //这里要用futuretask,否则不能加入thread构造方法 futuretask futuretask = new futuretask(callable); new thread(futuretask).start(); try { system.out.println(futuretask.get()); } catch (interruptedexception e) { e.printstacktrace(); } catch (executionexception e) { e.printstacktrace(); } } //线程池实现 @test public void test4() { executorservice executorservice = executors.newfixedthreadpool(5); //execute直接执行线程 executorservice.execute(new thread()); executorservice.execute(new runnable() { @override public void run() { system.out.println("runnable"); } }); //submit提交有返回结果的任务,运行完后返回结果。 future future = executorservice.submit(new callable<string>() { @override public string call() throws exception { return "a"; } }); try { system.out.println(future.get()); } catch (interruptedexception e) { e.printstacktrace(); } catch (executionexception e) { e.printstacktrace(); } arraylist<string> list = new arraylist<>(); //有返回值的线程组将返回值存进集合 for (int i = 0;i < 5;i ++ ) { int finali = i; future future1 = executorservice.submit(new callable<string>() { @override public string call() throws exception { return "res" + finali; } }); try { list.add((string) future1.get()); } catch (interruptedexception e) { e.printstacktrace(); } catch (executionexception e) { e.printstacktrace(); } } for (string s : list) { system.out.println(s); } }
}
线程状态转换
public class 线程的状态转换 { //一开始线程是init状态,结束时是terminated状态 class t implements runnable { private string name; public t(string name) { this.name = name; } @override public void run() { system.out.println(name + "run"); } } //测试join,父线程在子线程运行时进入waiting状态 @test public void test1() throws interruptedexception { thread dad = new thread(new runnable() { thread son = new thread(new t("son")); @override public void run() { system.out.println("dad init"); son.start(); try { //保证子线程运行完再运行父线程 son.join(); system.out.println("dad run"); } catch (interruptedexception e) { e.printstacktrace(); } } }); //调用start,线程进入runnable状态,等待系统调度 dad.start(); //在父线程中对子线程实例使用join,保证子线程在父线程之前执行完 } //测试sleep @test public void test2(){ thread t1 = new thread(new runnable() { @override public void run() { system.out.println("t1 run"); try { thread.sleep(3000); } catch (interruptedexception e) { e.printstacktrace(); } } }); //主线程休眠。进入time waiting状态 try { thread.sleep(3000); } catch (interruptedexception e) { e.printstacktrace(); } t1.start(); } //线程2进入blocked状态。 public static void main(string[] args) { test4(); thread.yield();//进入runnable状态 } //测试blocked状态 public static void test4() { class a { //线程1获得实例锁以后线程2无法获得实例锁,所以进入blocked状态 synchronized void run() { while (true) { system.out.println("run"); } } } a a = new a(); new thread(new runnable() { @override public void run() { system.out.println("t1 get lock"); a.run(); } }).start(); new thread(new runnable() { @override public void run() { system.out.println("t2 get lock"); a.run(); } }).start(); } //volatile保证线程可见性 volatile static int flag = 1; //object作为锁对象,用于线程使用wait和notify方法 volatile static object o = new object(); //测试wait和notify //wait后进入waiting状态,被notify进入blocked(阻塞等待锁释放)或者runnable状态(获取到锁) public void test5() { new thread(new runnable() { @override public void run() { //wait和notify只能在同步代码块内使用 synchronized (o) { while (true) { if (flag == 0) { try { thread.sleep(2000); system.out.println("thread1 wait"); //释放锁,线程挂起进入object的等待队列,后续代码运行 o.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } system.out.println("thread1 run"); system.out.println("notify t2"); flag = 0; //通知等待队列的一个线程获取锁 o.notify(); } } } }).start(); //解释同上 new thread(new runnable() { @override public void run() { while (true) { synchronized (o) { if (flag == 1) { try { thread.sleep(2000); system.out.println("thread2 wait"); o.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } system.out.println("thread2 run"); system.out.println("notify t1"); flag = 1; o.notify(); } } } }).start(); } //输出结果是 // thread1 run // notify t2 // thread1 wait // thread2 run // notify t1 // thread2 wait // thread1 run // notify t2 //不断循环 }
java thread常用方法
thread#yield():
执行此方法会向系统线程调度器(schelduler)发出一个暗示,告诉其当前java线程打算放弃对cpu的使用,但该暗示,有可能被调度器忽略。使用该方法,可以防止线程对cpu的过度使用,提高系统性能。
thread#sleep(time)或thread.sleep(time, nanos):
使当前线程进入休眠阶段,状态变为:time_waiting
thread.interrupt():
中断当前线程的执行,允许当前线程对自身进行中断,否则将会校验调用方线程是否有对该线程的权限。
如果当前线程因被调用object#wait(),object#wait(long, int), 或者线程本身的join(), join(long),sleep()处于阻塞状态中,此时调用interrupt方法会使抛出interruptedexception,而且线程的阻塞状态将会被清除。
thread#interrupted(),返回true或者false:
查看当前线程是否处于中断状态,这个方法比较特殊之处在于,如果调用成功,会将当前线程的interrupt status清除。所以如果连续2次调用该方法,第二次将返回false。
thread.isinterrupted(),返回true或者false:
与上面方法相同的地方在于,该方法返回当前线程的中断状态。不同的地方在于,它不会清除当前线程的interrupt status状态。
thread#join(),thread#join(time):
a线程调用b线程的join()方法,将会使a等待b执行,直到b线程终止。如果传入time参数,将会使a等待b执行time的时间,如果time时间到达,将会切换进a线程,继续执行a线程。
构造方法和守护线程
构造方法 thread类中不同的构造方法接受如下参数的不同组合: 一个runnable对象,这种情况下,thread.start方法将会调用对应runnable对象的run方法。如果没有提供runnable对象,那么就会立即得到一个thread.run的默认实现。 一个作为线程标识名的string字符串,该标识在跟踪和调试过程中会非常有用,除此别无它用。 线程组(threadgroup),用来放置新创建的线程,如果提供的threadgroup不允许被访问,那么就会抛出一个securityexception 。 thread对象拥有一个守护(daemon)标识属性,这个属性无法在构造方法中被赋值,但是可以在线程启动之前设置该属性(通过setdaemon方法)。 当程序中所有的非守护线程都已经终止,调用setdaemon方法可能会导致虚拟机粗暴的终止线程并退出。 isdaemon方法能够返回该属性的值。守护状态的作用非常有限,即使是后台线程在程序退出的时候也经常需要做一些清理工作。 (daemon的发音为”day-mon”,这是系统编程传统的遗留,系统守护进程是一个持续运行的进程,比如打印机队列管理,它总是在系统中运行。)
启动线程的方式和isalive方法
启动线程
调用start方法会触发thread实例以一个新的线程启动其run方法。新线程不会持有调用线程的任何同步锁。
当一个线程正常地运行结束或者抛出某种未检测的异常(比如,运行时异常(runtimeexception),错误(error) 或者其子类)线程就会终止。
当线程终止之后,是不能被重新启动的。在同一个thread上调用多次start方法会抛出invalidthreadstateexception异常。
如果线程已经启动但是还没有终止,那么调用isalive方法就会返回true.即使线程由于某些原因处于阻塞(blocked)状态该方法依然返回true。
如果线程已经被取消(cancelled),那么调用其isalive在什么时候返回false就因各java虚拟机的实现而异了。没有方法可以得知一个处于非活动状态的线程是否已经被启动过了。
java多线程优先级
java的线程实现基本上都是内核级线程的实现,所以java线程的具体执行还取决于操作系统的特性。
java虚拟机为了实现跨平台(不同的硬件平台和各种操作系统)的特性,java语言在线程调度与调度公平性上未作出任何的承诺,甚至都不会严格保证线程会被执行。但是java线程却支持优先级的方法,这些方法会影响线程的调度:
每个线程都有一个优先级,分布在thread.min_priority和thread.max_priority之间(分别为1和10)
默认情况下,新创建的线程都拥有和创建它的线程相同的优先级。main方法所关联的初始化线程拥有一个默认的优先级,这个优先级是thread.norm_priority (5).
线程的当前优先级可以通过getpriority方法获得。
线程的优先级可以通过setpriority方法来动态的修改,一个线程的最高优先级由其所在的线程组限定。
java多线程面试题
这篇文章主要是对多线程的问题进行总结的,因此罗列了40个多线程的问题。
这些多线程的问题,有些来源于各大网站、有些来源于自己的思考。可能有些问题网上有、可能有些问题对应的答案也有、也可能有些各位网友也都看过,但是本文写作的重心就是所有的问题都会按照自己的理解回答一遍,不会去看网上的答案,因此可能有些问题讲的不对,能指正的希望大家不吝指教。
1、多线程有什么用?
一个可能在很多人看来很扯淡的一个问题:我会用多线程就好了,还管它有什么用?在我看来,这个回答更扯淡。所谓"知其然知其所以然","会用"只是"知其然","为什么用"才是"知其所以然",只有达到"知其然知其所以然"的程度才可以说是把一个知识点运用自如。ok,下面说说我对这个问题的看法:
1)发挥多核cpu的优势
随着工业的进步,现在的笔记本、台式机乃至商用的应用服务器至少也都是双核的,4核、8核甚至16核的也都不少见,如果是单线程的程序,那么在双核cpu上就浪费了50%,在4核cpu上就浪费了75%。单核cpu上所谓的"多线程"那是假的多线程,同一时间处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程"同时"运行罢了。多核cpu上的多线程才是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核cpu的优势来,达到充分利用cpu的目的。
2)防止阻塞
从程序运行效率的角度来看,单核cpu不但不会发挥出多线程的优势,反而会因为在单核cpu上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核cpu我们还是要应用多线程,就是为了防止阻塞。试想,如果单核cpu使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
3)便于建模
这是另外一个没有这么明显的优点了。假设有一个大的任务a,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务a分解成几个小任务,任务b、任务c、任务d,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。
2、创建线程的方式
比较常见的一个问题了,一般就是两种:
1)继承thread类
2)实现runnable接口
至于哪个好,不用说肯定是后者好,因为实现接口的方式比继承类的方式更灵活,也能减少程序之间的耦合度,面向接口编程也是设计模式6大原则的核心。
3、start()方法和run()方法的区别
只有调用了start()方法,才会表现出多线程的特性,不同线程的run()方法里面的代码交替执行。如果只是调用run()方法,那么代码还是同步执行的,必须等待一个线程的run()方法里面的代码全部执行完毕之后,另外一个线程才可以执行其run()方法里面的代码。
4、runnable接口和callable接口的区别
有点深的问题了,也看出一个java程序员学习知识的广度。
runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;callable接口中的call()方法是有返回值的,是一个泛型,和future、futuretask配合可以用来获取异步执行的结果。
这其实是很有用的一个特性,因为多线程相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而callable+future/futuretask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,真的是非常有用。
5、cyclicbarrier和countdownlatch的区别
两个看上去有点像的类,都在java.util.concurrent下,都可以用来表示代码运行到某个点上,二者的区别在于:
1)cyclicbarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;countdownlatch则不是,某线程运行到某个点上之后,只是给某个数值-1而已,该线程继续运行。
2)cyclicbarrier只能唤起一个任务,countdownlatch可以唤起多个任务。
3) cyclicbarrier可重用,countdownlatch不可重用,计数值为0该countdownlatch就不可再用了。
6、volatile关键字的作用
一个非常重要的问题,是每个学习、应用多线程的java程序员都必须掌握的。理解volatile关键字的作用的前提是要理解java内存模型,这里就不讲java内存模型了,可以参见第31点,volatile关键字的作用主要有两个:
1)多线程主要围绕可见性和原子性两个特性而展开,使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据。
2)代码底层执行不像我们看到的高级语言----java程序这么简单,它的执行是java代码-->字节码-->根据字节码执行对应的c/c++代码-->c/c++代码被编译成汇编语言-->和硬件电路交互,现实中,为了获取更好的性能jvm可能会对指令进行重排序,多线程下可能会出现一些意想不到的问题。使用volatile则会对禁止语义重排序,当然这也一定程度上降低了代码执行效率。
从实践角度而言,volatile的一个重要作用就是和cas结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如atomicinteger,更多详情请点击这里进行学习。
7、什么是线程安全
又是一个理论的问题,各式各样的答案有很多,我给出一个个人认为解释地最好的:如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
这个问题有值得一提的地方,就是线程安全也是有几个级别的:
1)不可变
像string、integer、long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用
2)绝对线程安全
不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,java中也有,比方说copyonwritearraylist、copyonwritearrayset
3)相对线程安全
相对线程安全也就是我们通常意义上所说的线程安全,像vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个vector、有个线程同时在add这个vector,99%的情况下都会出现concurrentmodificationexception,也就是fail-fast机制。
4)线程非安全
这个就没什么好说的了,arraylist、linkedlist、hashmap等都是线程非安全的类,点击这里了解为什么不安全。
8、java中如何获取到线程dump文件
死循环、死锁、阻塞、页面打开慢等问题,打线程dump是最好的解决问题的途径。所谓线程dump也就是线程堆栈,获取到线程堆栈有两步:
1)获取到线程的pid,可以通过使用jps命令,在linux环境下还可以使用ps -ef | grep java
2)打印线程堆栈,可以通过使用jstack pid命令,在linux环境下还可以使用kill -3 pid
另外提一点,thread类提供了一个getstacktrace()方法也可以用于获取线程堆栈。这是一个实例方法,因此此方法是和具体线程实例绑定的,每次获取获取到的是具体某个线程当前运行的堆栈。
9、一个线程如果出现了运行时异常会怎么样
如果这个异常没有被捕获的话,这个线程就停止执行了。另外重要的一点是:如果这个线程持有某个某个对象的监视器,那么这个对象监视器会被立即释放
10、如何在两个线程之间共享数据
通过在线程之间共享对象就可以了,然后通过wait/notify/notifyall、await/signal/signalall进行唤起和等待,比方说阻塞队列blockingqueue就是为线程之间共享数据而设计的
11、sleep方法和wait方法有什么区别
这个问题常问,sleep方法和wait方法都可以用来放弃cpu一定的时间,不同点在于如果线程持有某个对象的监视器,sleep方法不会放弃这个对象的监视器,wait方法会放弃这个对象的监视器
12、生产者消费者模型的作用是什么
这个问题很理论,但是很重要:
1)通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用
2)解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约
13、threadlocal有什么用
简单说threadlocal就是一种以空间换时间的做法,在每个thread里面维护了一个以开地址法实现的threadlocal.threadlocalmap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了
14、为什么wait()方法和notify()/notifyall()方法要在同步块中被调用
这是jdk强制的,wait()方法和notify()/notifyall()方法在调用前都必须先获得对象的锁
15、wait()方法和notify()/notifyall()方法在放弃对象监视器时有什么区别
wait()方法和notify()/notifyall()方法在放弃对象监视器的时候的区别在于:wait()方法立即释放对象监视器,notify()/notifyall()方法则会等待线程剩余代码执行完毕才会放弃对象监视器。
16、为什么要使用线程池
避免频繁地创建和销毁线程,达到线程对象的重用。另外,使用线程池还可以根据项目灵活地控制并发的数目。点击这里学习线程池详解。
17、怎么唤醒一个阻塞的线程
如果线程是因为调用了wait()、sleep()或者join()方法而导致的阻塞,可以中断线程,并且通过抛出interruptedexception来唤醒它;如果线程遇到了io阻塞,无能为力,因为io是操作系统实现的,java代码并没有办法直接接触到操作系统。
18、不可变对象对多线程有什么帮助
前面有提到过的一个问题,不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。
19、什么是多线程的上下文切换
多线程的上下文切换是指cpu控制权由一个已经正在运行的线程切换到另外一个就绪并等待获取cpu执行权的线程的过程。
20、线程类的构造方法、静态块是被哪个线程调用的
这是一个非常刁钻和狡猾的问题。请记住:线程类的构造方法、静态块是被new这个线程类所在的线程所调用的,而run方法里面的代码才是被线程自身所调用的。
如果说上面的说法让你感到困惑,那么我举个例子,假设thread2中new了thread1,main函数中new了thread2,那么:
1)thread2的构造方法、静态块是main线程调用的,thread2的run()方法是thread2自己调用的
2)thread1的构造方法、静态块是thread2调用的,thread1的run()方法是thread1自己调用的
21、高并发、任务执行时间短的业务怎样使用线程池?并发不高、任务执行时间长的业务怎样使用线程池?并发高、业务执行时间长的业务怎样使用线程池?
这是我在并发编程网上看到的一个问题,把这个问题放在最后一个,希望每个人都能看到并且思考一下,因为这个问题非常好、非常实际、非常专业。关于这个问题,个人看法是:
1)高并发、任务执行时间短的业务,线程池线程数可以设置为cpu核数+1,减少线程上下文的切换
2)并发不高、任务执行时间长的业务要区分开看:
a)假如是业务时间长集中在io操作上,也就是io密集型的任务,因为io操作并不占用cpu,所以不要让所有的cpu闲下来,可以加大线程池中的线程数目,让cpu处理更多的业务
b)假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,和(1)一样吧,线程池中的线程数设置得少一些,减少线程上下文的切换
c)并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考其他有关线程池的文章。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦。
参考文章
https://blog.csdn.net/zl1zl2zl3/article/details/81868173
https://www.runoob.com/java/java-multithreading.html
https://blog.csdn.net/qq_38038480/article/details/80584715
https://blog.csdn.net/tongxuexie/article/details/80145663
https://www.cnblogs.com/snow-flower/p/6114765.html
微信公众号
java技术江湖
如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号【java技术江湖】一位阿里 java 工程师的技术小站,作者黄小斜,专注 java 相关技术:ssm、springboot、mysql、分布式、中间件、集群、linux、网络、多线程,偶尔讲点docker、elk,同时也分享技术干货和学习经验,致力于java全栈开发!
java工程师必备学习资源: 一些java工程师常用学习资源,关注公众号后,后台回复关键字 “java” 即可免费无套路获取。
个人公众号:黄小斜
作者是 985 硕士,蚂蚁金服 java 工程师,专注于 java 后端技术栈:springboot、mysql、分布式、中间件、微服务,同时也懂点投资理财,偶尔讲点算法和计算机理论基础,坚持学习和写作,相信终身学习的力量!
程序员3t技术学习资源: 一些程序员学习技术的资源大礼包,关注公众号后,后台回复关键字 “资料” 即可免费无套路获取。