在Java开发中,常常须要创立线程去执行一些工作,实现起来也十分不便,但如果并发的线程数量很多,并且每个线程都是执行一个工夫很短的工作就完结了,这样频繁创立线程就会大大降低零碎的效率,因为频繁创立线程和销毁线程须要工夫。此时,咱们很天然会想到应用线程池来解决这个问题。
应用线程池的益处:
升高资源耗费。java中所有的池化技术都有一个益处,就是通过复用池中的对象,升高系统资源耗费。构想一下如果咱们有n多个子工作须要执行,如果咱们为每个子工作都创立一个执行线程,而创立线程的过程是须要肯定的零碎耗费的,最初必定会拖慢整个零碎的处理速度。而通过线程池咱们能够做到复用线程,工作有多个,但执行工作的线程能够通过线程池来复用,这样缩小了创立线程的开销,系统资源利用率失去了晋升。
升高治理线程的难度。多线程环境下对线程的治理是最容易呈现问题的,而线程池通过框架为咱们升高了治理线程的难度。咱们不必再去放心何时该销毁线程,如何最大限度的防止多线程的资源竞争。这些事件线程池都帮咱们代劳了。
晋升工作处理速度。线程池中长期驻留了肯定数量的活线程,当工作须要执行时,咱们不用先去创立线程,线程池会本人抉择利用现有的活线程来解决工作。
很显然,线程池一个很显著的特色就是“长期驻留了肯定数量的活线程”,防止了频繁创立线程和销毁线程的开销,那么它是如何做到的呢?咱们晓得一个线程只有执行完了run()办法内的代码,这个线程的使命就实现了,期待它的就是销毁。既然这是个“活线程”,天然是不能很快就销毁的。为了搞清楚这个“活线程”是如何工作的,上面通过追踪源码来看看能不能解开这个疑难。
学习过线程池都晓得,能够通过工厂类Executors来创个多种类型的线程池,局部类型如下:
public static ExecutorService newFixedThreadPool(int var0) { return new ThreadPoolExecutor(var0, var0, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());}public static ExecutorService newSingleThreadExecutor() { return new Executors.FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));}public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, 2147483647, 60L, TimeUnit.SECONDS, new SynchronousQueue());}public static ScheduledExecutorService newSingleThreadScheduledExecutor() { return new Executors.DelegatedScheduledExecutorService(new ScheduledThreadPoolExecutor(1));}public static ScheduledExecutorService newScheduledThreadPool(int var0) { return new ScheduledThreadPoolExecutor(var0);}
无论哪种类型的线程池,最终都是间接或者间接通过ThreadPoolExecutor这个类来实现的。而ThreadPoolExecutor的有多个构造方法,最终都是调用含有7个参数的构造函数。
/** * Creates a new {@code ThreadPoolExecutor} with the given initial * parameters. * * @param corePoolSize the number of threads to keep in the pool, even * if they are idle, unless {@code allowCoreThreadTimeOut} is set * @param maximumPoolSize the maximum number of threads to allow in the * pool * @param keepAliveTime when the number of threads is greater than * the core, this is the maximum time that excess idle threads * will wait for new tasks before terminating. * @param unit the time unit for the {@code keepAliveTime} argument * @param workQueue the queue to use for holding tasks before they are * executed. This queue will hold only the {@code Runnable} * tasks submitted by the {@code execute} method. * @param threadFactory the factory to use when the executor * creates a new thread * @param handler the handler to use when execution is blocked * because the thread bounds and queue capacities are reached * @throws IllegalArgumentException if one of the following holds:<br> * {@code corePoolSize < 0}<br> * {@code keepAliveTime < 0}<br> * {@code maximumPoolSize <= 0}<br> * {@code maximumPoolSize < corePoolSize} * @throws NullPointerException if {@code workQueue} * or {@code threadFactory} or {@code handler} is null */public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler;}
① corePoolSize
顾名思义,其指代外围线程的数量。当提交一个工作到线程池时,线程池会创立一个外围线程来执行工作,即便其余闲暇的外围线程可能执行新工作也会创立新的外围线程,而等到须要执行的工作数大于线程池外围线程的数量时就不再创立,这里也能够了解为当外围线程的数量等于线程池容许的外围线程最大数量的时候,如果有新工作来,就不会创立新的外围线程。
如果你想要提前创立并启动所有的外围线程,能够调用线程池的prestartAllCoreThreads()办法。
② maximumPoolSize
顾名思义,其指代线程池容许创立的最大线程数。如果队列满了,并且已创立的线程数小于最大线程数,则线程池会再创立新的线程执行工作。所以只有队列满了的时候,这个参数才有意义。因而当你应用了无界工作队列的时候,这个参数就没有成果了。
③ keepAliveTime
顾名思义,其指代线程流动放弃工夫,即当线程池的工作线程闲暇后,放弃存活的工夫。所以,如果工作很多,并且每个工作执行的工夫比拟短,能够调大工夫,进步线程的利用率,不然线程刚执行完一个工作,还没来得及解决下一个工作,线程就被终止,而须要线程的时候又再次创立,刚创立完不久执行工作后,没多少工夫又终止,会导致资源节约。
留神:这里指的是外围线程池以外的线程。还能够设置allowCoreThreadTimeout = true这样就会让外围线程池中的线程有了存活的工夫。
④ TimeUnit
顾名思义,其指代线程流动放弃工夫的单位:可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS,千分之一微秒)。
⑤ workQueue
顾名思义,其指代工作队列:用来保留期待执行工作的阻塞队列。
⑥ threadFactory
顾名思义,其指代创立线程的工厂:能够通过线程工厂给每个创立进去的线程设置更加有意义的名字。
⑦ RejectedExecutionHandler
顾名思义,其指代回绝执行程序,能够了解为饱和策略:当队列和线程池都满了,阐明线程池处于饱和状态,那么必须采取一种策略解决提交的新工作。这个策略默认状况下是AbortPolicy,示意无奈解决新工作时抛出异样。在JDK1.5中Java线程池框架提供了以下4种策略。
AbortPolicy:间接抛出异样RejectedExecutionException。
CallerRunsPolicy:只用调用者所在线程来运行工作,即由调用 execute办法的线程执行该工作。
DiscardOldestPolicy:抛弃队列里最近的一个工作,并执行当前任务。
DiscardPolicy:不解决,抛弃掉,即抛弃且不抛出异样。
这7个参数独特决定了线程池执行一个工作的策略:
当一个工作被增加进线程池时:
- 线程数量未达到 corePoolSize,则新建一个线程(外围线程)执行工作
- 线程数量达到了 corePools,则将工作移入队列期待
- 队列已满,新建线程(非核心线程)执行工作
- 队列已满,总线程数又达到了 maximumPoolSize,就会由下面那位星期天(RejectedExecutionHandler)抛出异样
说白了就是先利用外围线程,外围线程用完,新来的就退出期待队列,一旦队列满了,那么只能开始非核心线程来执行了。
下面的策略,会在浏览代码的时候体现进去,并且在代码中也能窥探出真正复用闲暇线程的实现原理。
接下来咱们就从线程池执行工作的入口剖析。
一个线程池能够接受任务类型有Runnable和Callable,别离对应了execute和submit办法。目前咱们只剖析execute的执行过程。
上源码:
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); /* * Proceed in 3 steps: * * 1. If fewer than corePoolSize threads are running, try to * start a new thread with the given command as its first * task. The call to addWorker atomically checks runState and * workerCount, and so prevents false alarms that would add * threads when it shouldn't, by returning false. * * 2. If a task can be successfully queued, then we still need * to double-check whether we should have added a thread * (because existing ones died since last checking) or that * the pool shut down since entry into this method. So we * recheck state and if necessary roll back the enqueuing if * stopped, or start a new thread if there are none. * * 3. If we cannot queue task, then we try to add a new * thread. If it fails, we know we are shut down or saturated * and so reject the task. */ int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { //第一步:如果线程数量小于外围线程数 if (addWorker(command, true))//则启动一个外围线程执行工作 return; c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) {//第二步:以后线程数量大于等于外围线程数,退出工作队列,胜利的话会进行二次查看 int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false);//启动非核心线程执行,留神这里工作是null,其实外面会去取工作队列里的工作执行 } else if (!addWorker(command, false))//第三步:退出不了队列(即队列满了),尝试启动非核心线程 reject(command);//如果启动不了非核心线程执行,阐明达到了最大线程数量的限度,会应用第7个参数抛出异样}
代码并不多,次要分三个步骤,其中有两个静态方法常常被用到,次要用来判断线程池的状态和无效线程数量:
// 获取运行状态private static int runStateOf(int c) { return c & ~CAPACITY; }// 获取流动线程数private static int workerCountOf(int c) { return c & CAPACITY; }
总结一下,execute的执行逻辑就是:
- 如果 以后流动线程数 < 指定的外围线程数,则创立并启动一个线程来执行新提交的工作(此时新建的线程相当于外围线程);
- 如果 以后流动线程数 >= 指定的外围线程数,且缓存队列未满,则将工作增加到缓存队列中;
- 如果 以后流动线程数 >= 指定的外围线程数,且缓存队列已满,则创立并启动一个线程来执行新提交的工作(此时新建的线程相当于非核心线程);
从代码中咱们也能够看出,即使以后流动的线程有闲暇的,只有这个流动的线程数量小于设定的外围线程数,那么依旧会启动一个新线程来执行工作。也就是说不会去复用任何线程。在execute办法外面咱们没有看到线程复用的影子,那么咱们持续来看看addWorker办法。
private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs >= SHUTDOWN && ! (rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty())) return false; for (;;) { int wc = workerCountOf(c); if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize)) return false; if (compareAndIncrementWorkerCount(c)) break retry; c = ctl.get(); // Re-read ctl if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } //后面都是线程池状态的判断,临时不理睬,次要看上面两个要害的中央 boolean workerStarted = false; boolean workerAdded = false; Worker w = null; try { w = new Worker(firstTask); // 新建一个Worker对象,这个对象蕴含了待执行的工作,并且新建一个线程 final Thread t = w.thread; if (t != null) { final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { // Recheck while holding lock. // Back out on ThreadFactory failure or if // shut down before lock acquired. int rs = runStateOf(ctl.get()); if (rs < SHUTDOWN || (rs == SHUTDOWN && firstTask == null)) { if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); workers.add(w); int s = workers.size(); if (s > largestPoolSize) largestPoolSize = s; workerAdded = true; } } finally { mainLock.unlock(); } if (workerAdded) { t.start(); // 启动刚创立的worker对象外面的thread执行 workerStarted = true; } } } finally { if (! workerStarted) addWorkerFailed(w); } return workerStarted;}
办法尽管有点长,然而咱们只思考两个要害的中央,先是创立一个worker对象,创立胜利后,对线程池状态判断胜利后,就去执行该worker对象的thread的启动。也就是说在这个办法外面启动了一个关联到worker的线程,然而这个线程是如何执行咱们传进来的runnable工作的呢?接下来看看这个Worker对象到底做了什么。
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{ /** * This class will never be serialized, but we provide a * serialVersionUID to suppress a javac warning. */ private static final long serialVersionUID = 6138294804551838833L; /** Thread this worker is running in. Null if factory fails. */ final Thread thread; /** Initial task to run. Possibly null. */ Runnable firstTask; /** Per-thread task counter */ volatile long completedTasks; /** * Creates with given first task and thread from ThreadFactory. * @param firstTask the first task (null if none) */ Worker(Runnable firstTask) { setState(-1); // inhibit interrupts until runWorker this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); } /** Delegates main run loop to outer runWorker. */ public void run() { runWorker(this); } // Lock methods // // The value 0 represents the unlocked state. // The value 1 represents the locked state. protected boolean isHeldExclusively() { return getState() != 0; } protected boolean tryAcquire(int unused) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } protected boolean tryRelease(int unused) { setExclusiveOwnerThread(null); setState(0); return true; } public void lock() { acquire(1); } public boolean tryLock() { return tryAcquire(1); } public void unlock() { release(1); } public boolean isLocked() { return isHeldExclusively(); } void interruptIfStarted() { Thread t; if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) { try { t.interrupt(); } catch (SecurityException ignore) { } } }}
最重要的构造方法:
Worker(Runnable firstTask) { // worker自身实现了Runnable接口 setState(-1); // inhibit interrupts until runWorker this.firstTask = firstTask; // 持有内部传进来的runnable工作 //创立了一个thread对象,并把本身这个runnable对象给了thread,一旦该thread执行start办法,就会执行worker的run办法 this.thread = getThreadFactory().newThread(this); }在addWorker办法中执行的t.start会去执行worker的run办法:public void run() { runWorker(this); }run办法又执行了ThreadPoolExecutor的runWorker办法,把以后worker对象传入。final void runWorker(Worker w) { Thread wt = Thread.currentThread(); Runnable task = w.firstTask; // 取出worker的runnable工作 w.firstTask = null; w.unlock(); // allow interrupts boolean completedAbruptly = true; try { // 循环不断的判断工作是否为空,当第一个判断为false的时候,即task为null,这个task啥时候为null呢? // 要么w.firstTask为null,还记得咱们在execute办法第二步的时候,执行addWorker的时候传进来的runnable是null吗? // 要么是执行了一遍while循环,在上面的finally中执行了task=null; // 或者执行第二个判断,一旦不为空就会继续执行循环里的代码。 while (task != null || (task = getTask()) != null) { w.lock(); // If pool is stopping, ensure thread is interrupted; // if not, ensure thread is not interrupted. This // requires a recheck in second case to deal with // shutdownNow race while clearing interrupt if ((runStateAtLeast(ctl.get(), STOP) || (Thread.interrupted() && runStateAtLeast(ctl.get(), STOP))) && !wt.isInterrupted()) wt.interrupt(); try { beforeExecute(wt, task); Throwable thrown = null; try { task.run(); // 工作不为空,就会执行工作的run办法,也就是runnable的run办法 } catch (RuntimeException x) { thrown = x; throw x; } catch (Error x) { thrown = x; throw x; } catch (Throwable x) { thrown = x; throw new Error(x); } finally { afterExecute(task, thrown); } } finally { task = null; // 执行实现置null,持续下一个循环 w.completedTasks++; w.unlock(); } } completedAbruptly = false; } finally { processWorkerExit(w, completedAbruptly); }}
办法比拟长,归纳起来就三步:
1,从worker中取出runnable(这个对象有可能是null,见正文中的解释);
2,进入while循环判断,判断以后worker中的runnable,或者通过getTask失去的runnable是否为空,不为空的状况下,就执行run;
3,执行实现把runnable工作置为null。
如果咱们不思考此办法外面的while循环的第二个判断,在咱们的线程开启的时候,程序执行了runWorker办法后,以后worker的run就执行实现了。
既然执行完了那么这个线程也就没用了,只有期待虚拟机销毁了。那么回顾一下咱们的指标:Java线程池中的线程是如何被反复利用的?如同并没有反复利用啊,新建一个线程,执行一个工作,而后就完结了,销毁了。没什么特地的啊,难道有什么中央漏掉了,被忽略了?
认真回顾下该办法中的while循环的第二个判断(task = getTask)!=null
玄机就在getTask办法中。
private Runnable getTask() { boolean timedOut = false; // Did the last poll() time out? for (;;) { int c = ctl.get(); int rs = runStateOf(c); // Check if queue empty only if necessary. if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) { decrementWorkerCount(); return null; } int wc = workerCountOf(c); // timed变量用于判断是否须要进行超时管制。 // allowCoreThreadTimeOut默认是false,也就是外围线程不容许进行超时; // wc > corePoolSize,示意以后线程池中的线程数量大于外围线程数量; // 对于超过外围线程数量的这些线程或者容许外围线程进行超时管制的时候,须要进行超时管制 // Are workers subject to culling? boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; // 如果须要进行超时管制,且上次从缓存队列中获取工作时产生了超时(timedOut开始为false,前面的循环开端超时时会置为true) // 或者以后线程数量曾经超过了最大线程数量,那么尝试将workerCount减1,即以后流动线程数减1, if ((wc > maximumPoolSize || (timed && timedOut)) && (wc > 1 || workQueue.isEmpty())) { // 如果减1胜利,则返回null,这就意味着runWorker()办法中的while循环会被退出,其对应的线程就要销毁了,也就是线程池中少了一个线程了 if (compareAndDecrementWorkerCount(c)) return null; continue; } try { // 留神workQueue中的poll()办法与take()办法的区别 //poll形式取工作的特点是从缓存队列中取工作,最长期待keepAliveTime的时长,取不到返回null //take形式取工作的特点是从缓存队列中取工作,若队列为空,则进入阻塞状态,直到能取出对象为止 Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); if (r != null) return r; timedOut = true; // 能走到这里阐明曾经超时了 } catch (InterruptedException retry) { timedOut = false; } }}
正文曾经很分明了,getTask的作用就是,在以后线程中:
1,如果以后线程池线程数量大于外围线程数量或者设置了对外围线程进行超时管制的话(此时相当于对所有线程进行超时管制),就会去工作队列获取超时工夫内的工作(队列的poll办法),获取到的话就会继续执行工作,也就是执行runWorker办法中的while循环里的工作的run办法,执行实现后,又持续进入getTask从工作队列中获取下一个工作。如果在超时工夫内没有获取到工作,就会走到getTask的倒数第三行,设置timeOut标记为true,此时持续进入getTask的for循环中,因为超时了,那么就会进入尝试去去对线程数量-1操作,-1胜利了,就间接返回一个null的工作,这样就回到了以后线程执行的runWorker办法中,该办法的while循环判断getTask为空,间接退出循环,这样以后线程就执行实现了,意味着要被销毁了,这样天然就会被回收器择时回收了。也就是线程池中少了一个线程了。因而只有线程池中的线程数大于外围线程数(或者外围线程也容许超时)就会这样一个一个地销毁这些多余的线程。
2,如果以后流动线程数小于等于外围线程数(或者不容许外围线程超时),同样也是去缓存队列中取工作,但当缓存队列中没工作了,就会进入阻塞状态(队列的take办法),直到能取出工作为止(也就是队列中被新增加了工作时),因而这个线程是处于阻塞状态的,并不会因为缓存队列中没有工作了而被销毁。这样就保障了线程池有N个线程是活的,能够随时解决工作,从而达到反复利用的目标。
综上所述,线程之所以能达到复用,就是在以后线程执行的runWorker办法中有个while循环,while循环的第一个判断条件是执行以后线程关联的Worker对象中的工作,执行一轮后进入while循环的第二个判断条件getTask(),从工作队列中取工作,取这个工作的过程要么是始终阻塞的,要么是阻塞肯定工夫直到超时才完结的,超时到了的时候这个线程也就走到了生命的止境。
然而在咱们开始剖析execute的时候,这个办法中的三个局部都会调用addWorker去执行工作,在addWorker办法中都会去新建一个线程来执行工作,这样的话是不是每次execute都是去创立线程了?事实上,复用机制跟线程池的阻塞队列有很大关系,咱们能够看到,在execute在外围线程满了,然而队列不满的时候会把工作退出到队列中,一旦退出胜利,之前被阻塞的线程就会被唤醒去执行新的工作,这样就不会从新创立线程了。
咱们用个例子来看下:
假如咱们有这么一个ThreadPoolExecutor,外围线程数设置为5(不容许外围线程超时),最大线程数设置为10,超时工夫为20s,线程队列是LinkedBlockingDeque(相当于是个无界队列)。
当咱们给这个线程池陆续增加工作,前5个工作执行的时候,会执行到咱们之前剖析的execute办法的第一步局部,会陆续创立5个线程做为外围线程执行工作,以后线程外面的5个关联的工作执行实现后,会进入各自的while循环的第二个判断getTask中去取队列中的工作,假如以后没有新的工作过去也就是没有执行execute办法,那么这5个线程就会在workQueue.take()处始终阻塞的。这个时候,咱们执行execute退出一个工作,即第6个工作,这个时候会进入execute的第二局部,将工作退出到队列中,一旦退出队列,之前阻塞的5个线程其中一个就会被唤醒取出新退出的工作执行了。(这里有个execute的第二局部的后半段执行反复校验的代码即addWorker(传入null工作),目前还没搞明确是怎么回事)。
在咱们这个例子中,因为队列是无界的,所以始终不会执行到execute的第三局部即启动非核心线程,如果咱们设置队列为有界的,那么必然就会执行到这里了。
小结
通过以上的剖析,应该算是比较清楚地解答了“线程池中的外围线程是如何被反复利用的”这个问题,同时也对线程池的实现机制有了更进一步的了解:
当有新工作来的时候,先看看以后的线程数有没有超过外围线程数,如果没超过就间接新建一个线程来执行新的工作,如果超过了就看看缓存队列有没有满,没满就将新工作放进缓存队列中,满了就新建一个线程来执行新的工作,如果线程池中的线程数曾经达到了指定的最大线程数了,那就依据相应的策略回绝工作。
当缓存队列中的工作都执行完了的时候,线程池中的线程数如果大于外围线程数,就销毁多进去的线程,直到线程池中的线程数等于外围线程数。此时这些线程就不会被销毁了,它们始终处于阻塞状态,期待新的工作到来。
留神: 本文所说的“外围线程”、“非核心线程”是一个虚构的概念,是为了不便形容而虚构进去的概念,在代码中并没有哪个线程被标记为“外围线程”或“非核心线程”,所有线程都是一样的,只是当线程池中的线程多于指定的外围线程数量时,会将多进去的线程销毁掉,池中只保留指定个数的线程。那些被销毁的线程是随机的,可能是第一个创立的线程,也可能是最初一个创立的线程,或其它时候创立的线程。一开始我认为会有一些线程被标记为“外围线程”,而其它的则是“非核心线程”,在销毁多余线程的时候只销毁那些“非核心线程”,而“外围线程”不被销毁。这种了解是谬误的。
原文链接:https://blog.csdn.net/anhenzh...
版权申明:本文为CSDN博主「跑步_跑步」的原创文章,遵循CC 4.0 BY-SA版权协定,转载请附上原文出处链接及本申明。
近期热文举荐:
1.1,000+ 道 Java面试题及答案整顿(2021最新版)
2.终于靠开源我的项目弄到 IntelliJ IDEA 激活码了,真香!
3.阿里 Mock 工具正式开源,干掉市面上所有 Mock 工具!
4.Spring Cloud 2020.0.0 正式公布,全新颠覆性版本!
5.《Java开发手册(嵩山版)》最新公布,速速下载!
感觉不错,别忘了顺手点赞+转发哦!