前段时间在公司分享过两次java线程池的实现原理,但是貌似大家理解的不是很深入,在应用的时候发现被培训的人并没有抓住核心点,并不理解线程池的核心原理,所以再完整的梳理一遍源码,希望可以帮助大家理解线程池的核心逻辑。本篇着重讲解ThreadPoolExecutor的使用及其核心代码,关于Executors的使用请参考我的另一篇博客https://blog.csdn.net/leandzgc/article/details/103082437。
// 省略注释...
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
// 省略注释...
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}
// 省略注释...
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}
// 省略注释...
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue 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.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
核心线程数:int corePoolSize
不可小于0,初始创建的线程都是核心线程,线程池中正常情况下始终保留该大小的线程实例存活,哪怕没有任务空转也不会销毁该部分线程实例。
最大线程数:int maximumPoolSize
不可小于等于0,不可小于核心线程数,与核心数相等时没什么特殊作用。若大于核心数,且所有核心线程都处于繁忙状态,队列也已放满,新的任务提交时会创建新线程执行新任务(线程池内的工作线程实际上并未标记是否核心线程,只是逻辑上的核心线程及非核心线程,创建和销毁的最底层逻辑没区别)。线程池中实例化的最大线程对象数不会超过该参数值,若非核心线程处于空闲时间(当前任务执行完毕,不局限于第一次任务,若第一次刚执行完,来了新任务,非核心线程也会被复用的),且超过keepAliveTime配置的时间,则会销毁该非核心线程。
存活时间:long keepAliveTime
不可小于0,用于控制非核心线程数的空闲时存活时间(corePoolSize和maximumPoolSize一致时该参数配置了也没啥用)
存活时间的时间单位:TimeUnit unit
keepAliveTime参数对应的时间单位,可以是纳秒(TimeUnit.NANOSECONDS)、微秒(TimeUnit.MICROSECONDS)、毫秒(TimeUnit.MILLISECONDS)、秒(TimeUnit.SECONDS)、分钟(TimeUnit.MINUTES)、小时(TimeUnit.HOURS)、天(TimeUnit.DAYS)之间的一个。
线程池使用的阻塞队列:BlockingQueue
不可为空,当线程池中核心线程都处于繁忙状态,且有新的任务来临时,会把新的任务放入阻塞队列。阻塞队列放满后会尝试创建非核心线程,并使用非核心线程执行新任务。
线程池创建线程实例时使用的线程工厂:ThreadFactory threadFactory
不可为空,创建线程时使用该工厂构建线程池中的线程实例,不需要特殊处理可以给Executors.defaultThreadFactory(),使用自带的默认工厂。
线程池拒绝策略:RejectedExecutionHandler handler
不可为空,当核心线程数已耗尽且全部繁忙,队列已满,非核心线程数已耗尽且全部繁忙,会触发传入的拒绝策略。默认为AbortPolicy,直接抛出异常。自带了AbortPolicy、DiscardPolicy、DiscardOldestPolicy、CallerRunsPolicy四种,也可以自行实现RejectedExecutionHandler接口来处理。
提交任务到线程池有以下几种方法
调用execute:仅能传递Runnable的实现类,也就是不带返回值(ThreadPoolExecutor中实现了祖宗接口Executor中的方法,在父类AbstractExecutorService的submit方法中会触发,也可以直接调用)
ThreadPoolExecutor tpe = new ThreadPoolExecutor(N_NUM_10, N_NUM_30, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue(N_NUM_10));
tpe.execute(new Runnable() {
public void run() {
// 具体业务逻辑实现
}
});
方法声明
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
// 省略核心代码...
}
调用submit:父类AbstractExecutorService提供的方法,返回Future>接口的实现类,可直接实例化比较常用的FutureTask
// 创建一个future列表,用于接收线程执行结果
List> futureList = new ArrayList>(N_NUM_10);
// 创建线程池
ThreadPoolExecutor tpe = new ThreadPoolExecutor(N_NUM_10, N_NUM_30, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue(N_NUM_10));
// 提交线程任务
for (int i = 0; i <= N_NUM_10; i++) {
final Integer currNum = i;
Future future = tpe.submit(new Callable() {
public Boolean call() throws Exception {
// 具体业务逻辑实现
return currNum % N_NUM_2 == 0;
}
});
futureList.add(future);
}
int oddNum = 0;
int evenNum = 0;
// 等待线程执行完毕,并获取线程执行结果
for (Future future: futureList) {
try {
// 线程未执行完毕会阻塞
if (future.get()) {
evenNum ++;
} else {
oddNum ++;
}
} catch (Exception e) {
e.printStackTrace();
}
}
System.out.println("奇数共:" + oddNum + "个,偶数共:" + evenNum + "个");
tpe.shutdown();
方法声明(有三种方法提交,根据自己的实际需要提交即可,比较常用的是第三个)
public Future> submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
public Future submit(Runnable task, T result) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task, result);
execute(ftask);
return ftask;
}
public Future submit(Callable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task);
execute(ftask);
return ftask;
}
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.
*
* 1.如果正在运行的线程数少于corePoolSize,则尝试启动一个新的线程实例来运行给定的命令,
* addWorker方法里面会自动校验runState和workerCount的正确性,在不成功的时候会返回false(返回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.
*
* 2.如果加入队列成功(核心数已满,且均繁忙),系统还是会再次二次校验是否需要添加该线程(可能存在上次校验后线程池已终止的情况),
* 或者调用这个方法后线程被终止。
* 系统会再次校验线程池状态,在线程池已终止时会从队列中移除刚刚的命令,且调用拒绝策略通知调用者。或者启动一个新的非核心线程,
* 用于执行当前任务(所以线程是否终止咱们要反复确认的)
*
* 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.
*
* 3.如果任务无法成功加入队列,系统会尝试创建一个非核心线程来执行该任务。如果创建失败,
* 那么不是线程池已饱和(包括核心线程数、队列、非核心线程数)就是线程池已终止,直接调用拒绝策略来通知调用者拒绝该任务
*/
// 1、获取控制器
int c = ctl.get();
// 2、获取当前工作线程数量(当前工作线程数小于核心线程数会走第一个逻辑)
if (workerCountOf(c) < corePoolSize) {
// 2.1、尝试直接增加核心工作线程,若增加成功直接返回(不放队列,直接把任务转交给核心线程了。里面会标记实际线程的第一次任务)
if (addWorker(command, true))
return;
// 2.2、创建核心工作线程失败则继续走后续逻辑,先获取一下线程池当前控制器
c = ctl.get();
}
// 3、同样先校验是否正在运行,并且任务入列是否成功,不成功直接走另一个逻辑
if (isRunning(c) && workQueue.offer(command)) {
// 3.1、二次检查运行状态
int recheck = ctl.get();
// 3.2、当前线程池不是处于运行中的话,尝试从队列中清掉刚刚的任务(毕竟线程池都停止了,队列里面任务没啥用了)
if (! isRunning(recheck) && remove(command))
// 3.3、清除成功的话调用拒绝策略通知调用者(人家把任务给你了,你给扔掉了,总要通知人家一声)
reject(command);
// 3.4、没有清除成功校验一下当前工作线程是不是已经为空了
// 注意一点:正常情况下这里不会为空的,除非你的核心线程数配置了0,且所有工作线程的空闲时间都超过了配置的keepalivetime的值而被销毁了
else if (workerCountOf(recheck) == 0)
// 3.5、创建一个非核心线程空转(用于把队列中刚刚放进去的任务消耗掉)
addWorker(null, false);
}
// 4、尝试创建非核心线程执行任务(这里就是核心线程数及最大线程数的区别了,核心线程满了放队列,队列也满了就创建非核心工作线程)
else if (!addWorker(command, false))
// 非核心线程也创建失败的话,直接调用拒绝策略通知调用者,这活儿我干不了了
reject(command);
// 从上面的代码逻辑来看,提交任务时线程池的逻辑是 核心线程数未满则创建核心工作线程并执行当前任务(正常情况下不入列)>尝试放入队列(不创建非核心线程)>尝试创建非核心线程(工作线程总数大于核心线程数配置,且队列已满) 这么个顺序。但仅第一波是这样的逻辑哈,一旦非核心线程被创建了,执行完本次任务以后,会再次尝试获取下一个任务的,这时候如果队列里面始终有任务,非核心工作线程也不会被销毁,直到队列没有任务,非核心工作线程空闲下来,且超过keepalivetime以后才会被销毁
}
/**
* Checks if a new worker can be added with respect to current
* pool state and the given bound (either core or maximum). If so,
* the worker count is adjusted accordingly, and, if possible, a
* new worker is created and started, running firstTask as its
* first task. This method returns false if the pool is stopped or
* eligible to shut down. It also returns false if the thread
* factory fails to create a thread when asked. If the thread
* creation fails, either due to the thread factory returning
* null, or due to an exception (typically OutOfMemoryError in
* Thread.start()), we roll back cleanly.
*
* @param firstTask the task the new thread should run first (or
* null if none). Workers are created with an initial first task
* (in method execute()) to bypass queuing when there are fewer
* than corePoolSize threads (in which case we always start one),
* or when the queue is full (in which case we must bypass queue).
* Initially idle threads are usually created via
* prestartCoreThread or to replace other dying workers.
*
* @param core if true use corePoolSize as bound, else
* maximumPoolSize. (A boolean indicator is used here rather than a
* value to ensure reads of fresh values after checking other pool
* state).
* @return true if successful
*/
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
// 获取控制器实例
int c = ctl.get();
// 获取当前运行状态
int rs = runStateOf(c);
// Check if queue empty only if necessary.
// 校验当前运行状态是否为不可继续运行状态,若状态为不可运行,则直接返回false
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
// 获取当前工作的线程数
int wc = workerCountOf(c);
// 如果当前工作线程数已为线程池可容纳最大线程数,
// 或者当前线程数大于等于想要创建的工作线程类型对应配置值(核心线程与非核心线程),
// 直接返回false
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
// 尝试给控制器的当前工作线程数+1,若成功则跳出循环,执行后续逻辑
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 {
// 创建工作线程实例(外部传递的仅仅是Runnable、Callable或Thread类型的对象,仅仅是java对象而已,并不是系统级的线程)
// 这里大家一定要仔细理解,之前讲过很多次,很多人还是以为传递进来的命令对象就已经是系统级的线程了
// 实际并不是这样,外部传递的仅仅是一个普通的java对象而已,跟自定义bean没啥区别
w = new Worker(firstTask);
// 获取创建线程池中实际的线程(这个才是线程池内部实际运行的线程对象,跟操作系统对接的线程对象)
// 该对象在Worker的构造函数中创建
final Thread t = w.thread;
// 获取失败则代表创建线程对象失败了,上面的两个默认标记都是false
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();
// 标记工作线程启动成功
workerStarted = true;
}
}
} finally {
// 未启动成功时,清理失败的工作线程实例,会从工作队列集中删掉该对象
if (! workerStarted)
addWorkerFailed(w);
}
// 返回工作线程对象是否添加成功
return workerStarted;
}
final void runWorker(Worker w) {
// 获取当前线程对象
Thread wt = Thread.currentThread();
// 缓存待执行的任务(调用线程池的execute或submit提交的任务)
Runnable task = w.firstTask;
// 清空对象缓存的待执行任务(后续该对象的第一次任务已经标记为空,就可以实现线程复用了)
w.firstTask = null;
// 释放自己的对象锁(允许外部调用中断操作,因为中断时会先尝试获取工作线程锁,这里不释放会导致那里无法加锁)
// 为啥要释放锁呢?因为下面可能会存在工作线程长时间未获取待执行任务,处于空闲状态的情况。
w.unlock(); // allow interrupts
// 突然完成的标记(称之为异常终止标记比较合适,代表任务未成功按预期逻辑执行)
boolean completedAbruptly = true;
try {
// 前者是判断任务是否为工作线程实例化后第一次运行(不为空则代表第一次进入)
// 后者是从线程池的队列中获取待执行的任务(线程复用就在这里神奇的实现了)
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 {
// 调用任务执行前置方法(所以可以继承ThreadPoolExecutor,在自己的实现类里面重载该方法做一些特殊处理)
beforeExecute(wt, task);
Throwable thrown = null;
try {
// 调用任务实例的run方法(这里才是提交给线程池的线程任务对象有用的地方,只是为了让所有任务都有这个方法,好在这里调用)
// 原来咱们自己创建循环处理的任务时会使用new Thread ... while(true) {dosomething 休眠并执行下一次}
// 跟这里的逻辑没啥本质上的差异
task.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 {
// 调用任务执行后置方法(所以可以继承ThreadPoolExecutor,在自己的实现类里面重载该方法做一些特殊处理)
afterExecute(task, thrown);
}
} finally {
// 执行完毕后清空当前任务标记
task = null;
// 给工作线程的完成任务计数器+1
w.completedTasks++;
// 解锁本对象,进行下一次循环(死循环在这里)
w.unlock();
}
}
completedAbruptly = false;
} finally {
// 执行工作线程退出前的一些通用逻辑(给整个线程池已完成任务计数器计数,把当前线程从工作线程集中清除)
// 线程异常终止恢复机制就在这个方法里面了(这里会置换异常线程实例)
processWorkerExit(w, completedAbruptly);
}
}
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())) {
// 给工作线程计数器-1
decrementWorkerCount();
return null;
}
// 获取线程池当前工作线程数
int wc = workerCountOf(c);
// Are workers subject to culling?
// 是否需要校验工作线程超时销毁的标记(其实,核心线程只是当前保留的线程数而已,线程本身没有被标记是否为核心)
// 允许核心线程超时销毁,或者当前存活的工作线程超过了核心线程数
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
// (工作线程超过了最大线程数,或者超时销毁标记为真,且获取任务超时)
// (并且工作线程数大于1,或者任务队列已处于空的状态)
// 同时满足以上两个条件会直接返回null(getTask的地方遇到null会销毁当前工作线程)
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
// 根据工作线程是否超时销毁标记来确认从队列中获取任务的策略
// 为真则调用poll,超时则标记已超时,并运行下一次循环判断
// 为假则调用take,死等(这时候外部工作线程不会被销毁,死等,就实现了核心线程数始终存活的逻辑)
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
// 成功拿到任务则返回该任务
if (r != null)
return r;
// 标记超时(下一次就会满足timedOut条件,非核心线程会被销毁)
timedOut = true;
} catch (InterruptedException retry) {
// 获取任务异常则不处理,继续下一次循环判断
timedOut = false;
}
}
}
/**
* Initiates an orderly shutdown in which previously submitted
* tasks are executed, but no new tasks will be accepted.
* Invocation has no additional effect if already shut down.
*
* This method does not wait for previously submitted tasks to
* complete execution. Use {@link #awaitTermination awaitTermination}
* to do that.
执行该方法时会先有序的关闭当前线程池中的空闲的线程,标记线程池为关闭状态,不再接受新任务,
* 正在运行的任务不受影响(允许重复调用)。但是之前已提交未运行的任务不会被继续执行(也就是队列中还没来得及运行的任务不会继续运行)
*
* @throws SecurityException {@inheritDoc}
*/
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(SHUTDOWN);
interruptIdleWorkers();
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
}
/**
* Attempts to stop all actively executing tasks, halts the
* processing of waiting tasks, and returns a list of the tasks
* that were awaiting execution. These tasks are drained (removed)
* from the task queue upon return from this method.
*
* This method does not wait for actively executing tasks to
* terminate. Use {@link #awaitTermination awaitTermination} to
* do that.
*
*
There are no guarantees beyond best-effort attempts to stop
* processing actively executing tasks. This implementation
* cancels tasks via {@link Thread#interrupt}, so any task that
* fails to respond to interrupts may never terminate.
与shutdown的区别:该方法会尝试停止池子中所有存活的线程,而shutdown只会终止空闲的线程。所以想停止线程池时,要根据自己实际的情况选择调用shutdown还是shutdownNow
*
* @throws SecurityException {@inheritDoc}
*/
public List shutdownNow() {
List tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}
先尝试创建核心线程并执行任务
核心线程数已满,且均繁忙则将任务加入队列(正常情况下队列未满不会直接创建非核心线程)
入列失败则尝试创建非核心线程直接去执行该任务(顺序的,自上而下,所以入列失败的前提是核心线程已满且繁忙)
创建非核心线程失败时则调用拒绝策略,通知线程池调用者
加入队列并创建非核心线程执行该任务(因为不存在核心线程数,所以3.3.1
中的第一条规则不生效。且同时做了3.3.1
中的第二条规则和第三条规则)
创建非核心线程失败时则调用拒绝策略,通知线程池调用者
3.3.2
创建了非核心线程,且工作线程一直未被销毁)将任务加入队列(正常情况下队列未满不会直接创建非核心线程)
入列失败则尝试创建非核心线程直接去执行该任务(顺序的,自上而下)
创建非核心线程失败时则调用拒绝策略,通知线程池调用者
请使用线程池替代所有的new Thread().start()
尽量不要使用Executors直接创建线程池
实例化线程池时一定要知道每个参数的作用,并根据自己实际情况实例化适合自己的线程池
先学会用,再问为什么要这样用,最后问这样用的原理是什么,自己是否能把核心代码写出来。不要仅仅停留在会用层面,会用很可能仅仅是咱们自己层面的会用
平常没事儿多看看大牛写的代码,真的是长知识呀!
人家把饭都做好并且都要喂嘴里,就等咱们张口就吃了,如果咱们还非得自己去学做饭,有点儿多此一举了。