第03讲:线程的状态有哪些?它是如何工作的?

线程(Thread)是并发编程的基础,也是程序执行的最小单元,它依托进程而存在。一个进程中可以包含多个线程,多线程可以共享一块内存空间和一组系统资源,因此线程之间的切换更加节省资源、更加轻量化,也因此被称为轻量级的进程。

当然,线程也是面试中常被问到的一个知识点,是程序员必备的基础技能,使用它可以有效地提高程序的整体运行速度。

本课时的面试问题是,线程的状态有哪些?它是如何工作的?

典型回答

线程的状态在 JDK 1.5 之后以枚举的方式被定义在 Thread 的源码中,它总共包含以下 6 个状态:

  • NEW,新建状态,线程被创建出来,但尚未启动时的线程状态;

  • RUNNABLE,就绪状态,表示可以运行的线程状态,它可能正在运行,或者是在排队等待操作系统给它分配 CPU 资源;

  • BLOCKED,阻塞等待锁的线程状态,表示处于阻塞状态的线程正在等待监视器锁,比如等待执行 synchronized 代码块或者使用 synchronized 标记的方法;

  • WAITING,等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作,比如,一个线程调用了 Object.wait() 方法,那它就在等待另一个线程调用 Object.notify() 或 Object.notifyAll() 方法;

  • TIMED_WAITING,计时等待状态,和等待状态(WAITING)类似,它只是多了超时时间,比如调用了有超时时间设置的方法 Object.wait(long timeout) 和 Thread.join(long timeout) 等这些方法时,它才会进入此状态;

  • TERMINATED,终止状态,表示线程已经执行完成。

线程状态的源代码如下:

public enum State {/*** 新建状态,线程被创建出来,但尚未启动时的线程状态*/NEW,

/**
      就绪状态,表示可以运行的线程状态,但它在排队等待来自操作系统的 CPU 资源
     
/
    RUNNABLE,

/**
      阻塞等待锁的线程状态,表示正在处于阻塞状态的线程
     
 正在等待监视器锁,比如等待执行 synchronized 代码块或者
      使用 synchronized 标记的方法
     
/
    BLOCKED,

/**
      等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作。
     
 例如,一个线程调用了 Object.wait() 它在等待另一个线程调用
      Object.notify() 或 Object.notifyAll()
     
/
    WAITING,

/**
      计时等待状态,和等待状态 (WAITING) 类似,只是多了超时时间,比如
     
 调用了有超时时间设置的方法 Object.wait(long timeout) 和 
      Thread.join(long timeout) 就会进入此状态
     
/
    TIMED_WAITING,

/**
      终止状态,表示线程已经执行完成
     
/
}

线程的工作模式是,首先先要创建线程并指定线程需要执行的业务方法,然后再调用线程的 start() 方法,此时线程就从 NEW(新建)状态变成了 RUNNABLE(就绪)状态,此时线程会判断要执行的方法中有没有 synchronized 同步代码块,如果有并且其他线程也在使用此锁,那么线程就会变为 BLOCKED(阻塞等待)状态,当其他线程使用完此锁之后,线程会继续执行剩余的方法。

当遇到 Object.wait() 或 Thread.join() 方法时,线程会变为 WAITING(等待状态)状态,如果是带了超时时间的等待方法,那么线程会进入 TIMED_WAITING(计时等待)状态,当有其他线程执行了 notify() 或 notifyAll() 方法之后,线程被唤醒继续执行剩余的业务方法,直到方法执行完成为止,此时整个线程的流程就执行完了,执行流程如下图所示:

考点分析

线程一般会作为并发编程的起始问题,用于引出更多的关于并发编程的面试问题。当然对于线程的掌握程度也决定了你对并发编程的掌握程度,通常面试官还会问:

  • BLOCKED(阻塞等待)和 WAITING(等待)有什么区别?

  • start() 方法和 run() 方法有什么区别?

  • 线程的优先级有什么用?该如何设置?

  • 线程的常用方法有哪些?

接下来我们一起来看这些问题的答案。

知识扩展

1.BLOCKED 和 WAITING 的区别

虽然 BLOCKED 和 WAITING 都有等待的含义,但二者有着本质的区别,首先它们状态形成的调用方法不同,其次 BLOCKED 可以理解为当前线程还处于活跃状态,只是在阻塞等待其他线程使用完某个锁资源;而 WAITING 则是因为自身调用了 Object.wait() 或着是 Thread.join() 又或者是 LockSupport.park() 而进入等待状态,只能等待其他线程执行某个特定的动作才能被继续唤醒,比如当线程因为调用了 Object.wait() 而进入 WAITING 状态之后,则需要等待另一个线程执行 Object.notify() 或 Object.notifyAll() 才能被唤醒。

2.start() 和 run() 的区别

首先从 Thread 源码来看,start() 方法属于 Thread 自身的方法,并且使用了 synchronized 来保证线程安全,源码如下:

public synchronized void start() {// 状态验证,不等于 NEW 的状态会抛出异常if (threadStatus != 0)throw new IllegalThreadStateException();// 通知线程组,此线程即将启动

group.add(this);
    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            // 不处理任何异常,如果 start0 抛出异常,则它将被传递到调用堆栈上
        }
    }
}

run() 方法为 Runnable 的抽象方法,必须由调用类重写此方法,重写的 run() 方法其实就是此线程要执行的业务方法,源码如下:

public class Thread implements Runnable {// 忽略其他方法......private Runnable target;@Overridepublic void run() {if (target != null) {target.run();}}
}
@FunctionalInterface
public interface Runnable {public abstract void run();
}

从执行的效果来说,start() 方法可以开启多线程,让线程从 NEW 状态转换成 RUNNABLE 状态,而 run() 方法只是一个普通的方法。

其次,它们可调用的次数不同,start() 方法不能被多次调用,否则会抛出 java.lang.IllegalStateException;而 run() 方法可以进行多次调用,因为它只是一个普通的方法而已。

3.线程优先级

在 Thread 源码中和线程优先级相关的属性有 3 个:

// 线程可以拥有的最小优先级
public final static int MIN_PRIORITY = 1;

// 线程默认优先级
public final static int NORM_PRIORITY = 5;

// 线程可以拥有的最大优先级
public final static int MAX_PRIORITY = 10

线程的优先级可以理解为线程抢占 CPU 时间片的概率,优先级越高的线程优先执行的概率就越大,但并不能保证优先级高的线程一定先执行。

在程序中我们可以通过 Thread.setPriority() 来设置优先级,setPriority() 源码如下:

public final void setPriority(int newPriority) {ThreadGroup g;checkAccess();// 先验证优先级的合理性if (newPriority > MAX_PRIORITY || newPriority < MIN_PRIORITY) {throw new IllegalArgumentException();}if((g = getThreadGroup()) != null) {// 优先级如果超过线程组的最高优先级,则把优先级设置为线程组的最高优先级if (newPriority > g.getMaxPriority()) {newPriority = g.getMaxPriority();}setPriority0(priority = newPriority);}
}

4.线程的常用方法

线程的常用方法有以下几个。

(1)join()

在一个线程中调用 other.join() ,这时候当前线程会让出执行权给 other 线程,直到 other 线程执行完或者过了超时时间之后再继续执行当前线程,join() 源码如下:

public final synchronized void join(long millis)
throws InterruptedException {long base = System.currentTimeMillis();long now = 0;// 超时时间不能小于 0if (millis < 0) {throw new IllegalArgumentException("timeout value is negative");}// 等于 0 表示无限等待,直到线程执行完为之if (millis == 0) {// 判断子线程 (其他线程) 为活跃线程,则一直等待while (isAlive()) {wait(0);}} else {// 循环判断while (isAlive()) {long delay = millis - now;if (delay <= 0) {break;}wait(delay);now = System.currentTimeMillis() - base;}}
}

从源码中可以看出 join() 方法底层还是通过 wait() 方法来实现的。

例如,在未使用 join() 时,代码如下:

public class ThreadExample {public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(() -> {for (int i = 1; i < 6; i++) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("子线程睡眠:" + i + "秒。");}});thread.start(); // 开启线程// 主线程执行for (int i = 1; i < 4; i++) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("主线程睡眠:" + i + "秒。");}}
}

程序执行结果为:

主线程睡眠:1秒。
子线程睡眠:1秒。
主线程睡眠:2秒。
子线程睡眠:2秒。
主线程睡眠:3秒。
子线程睡眠:3秒。
子线程睡眠:4秒。
子线程睡眠:5秒。

从结果可以看出,在未使用 join() 时主子线程会交替执行。

然后我们再把 join() 方法加入到代码中,代码如下:

public class ThreadExample {public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(() -> {for (int i = 1; i < 6; i++) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("子线程睡眠:" + i + "秒。");}});thread.start(); // 开启线程thread.join(2000); // 等待子线程先执行 2 秒钟// 主线程执行for (int i = 1; i < 4; i++) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("主线程睡眠:" + i + "秒。");}}
}

程序执行结果为:

子线程睡眠:1秒。
子线程睡眠:2秒。
主线程睡眠:1秒。
// thread.join(2000); 等待 2 秒之后,主线程和子线程再交替执行
子线程睡眠:3秒。
主线程睡眠:2秒。
子线程睡眠:4秒。
子线程睡眠:5秒。
主线程睡眠:3秒。

从执行结果可以看出,添加 join() 方法之后,主线程会先等子线程执行 2 秒之后才继续执行。

(2)yield()

看 Thread 的源码可以知道 yield() 为本地方法,也就是说 yield() 是由 C 或 C++ 实现的,源码如下:

public static native void yield();

yield() 方法表示给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。

比如我们执行这段包含了 yield() 方法的代码,如下所示:

public static void main(String[] args) throws InterruptedException {Runnable runnable = new Runnable() {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println("线程:" +Thread.currentThread().getName() + " I:" + i);if (i == 5) {Thread.yield();}}}};Thread t1 = new Thread(runnable, "T1");Thread t2 = new Thread(runnable, "T2");t1.start();t2.start();
}

当我们把这段代码执行多次之后会发现,每次执行的结果都不相同,这是因为 yield() 执行非常不稳定,线程调度器不一定会采纳 yield() 出让 CPU 使用权的建议,从而导致了这样的结果。

小结

本课时我们介绍了线程的 6 种状态以及线程的执行流程,还介绍了 BLOCKED(阻塞等待)和 WAITING(等待)的区别,start() 方法和 run() 方法的区别,以及 join() 方法和 yield() 方法的作用,但我们不能死记硬背,要多动手实践才能真正的理解这些知识点。


第04讲:详解 ThreadPoolExecutor 的参数含义及源码执行流程?

线程池是为了避免线程频繁的创建和销毁带来的性能消耗,而建立的一种池化技术,它是把已创建的线程放入“池”中,当有任务来临时就可以重用已有的线程,无需等待创建的过程,这样就可以有效提高程序的响应速度。但如果要说线程池的话一定离不开 ThreadPoolExecutor ,在阿里巴巴的《Java 开发手册》中是这样规定线程池的:

线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的读者更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors 返回的线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

  • CachedThreadPool 和 ScheduledThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

其实当我们去看 Executors 的源码会发现,Executors.newFixedThreadPool()、Executors.newSingleThreadExecutor() 和 Executors.newCachedThreadPool() 等方法的底层都是通过 ThreadPoolExecutor 实现的,所以本课时我们就重点来了解一下 ThreadPoolExecutor 的相关知识,比如它有哪些核心的参数?它是如何工作的?

典型回答

ThreadPoolExecutor 的核心参数指的是它在构建时需要传递的参数,其构造方法如下所示:

public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler) {if (corePoolSize < 0 ||// maximumPoolSize 必须大于 0,且必须大于 corePoolSizemaximumPoolSize <= 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;
}

第 1 个参数:corePoolSize 表示线程池的常驻核心线程数。如果设置为 0,则表示在没有任何任务时,销毁线程池;如果大于 0,即使没有任务时也会保证线程池的线程数量等于此值。但需要注意,此值如果设置的比较小,则会频繁的创建和销毁线程(创建和销毁的原因会在本课时的下半部分讲到);如果设置的比较大,则会浪费系统资源,所以开发者需要根据自己的实际业务来调整此值。

第 2 个参数:maximumPoolSize 表示线程池在任务最多时,最大可以创建的线程数。官方规定此值必须大于 0,也必须大于等于 corePoolSize,此值只有在任务比较多,且不能存放在任务队列时,才会用到。

第 3 个参数:keepAliveTime 表示线程的存活时间,当线程池空闲时并且超过了此时间,多余的线程就会销毁,直到线程池中的线程数量销毁的等于 corePoolSize 为止,如果 maximumPoolSize 等于 corePoolSize,那么线程池在空闲的时候也不会销毁任何线程。

第 4 个参数:unit 表示存活时间的单位,它是配合 keepAliveTime 参数共同使用的。

第 5 个参数:workQueue 表示线程池执行的任务队列,当线程池的所有线程都在处理任务时,如果来了新任务就会缓存到此任务队列中排队等待执行。

第 6 个参数:threadFactory 表示线程的创建工厂,此参数一般用的比较少,我们通常在创建线程池时不指定此参数,它会使用默认的线程创建工厂的方法来创建线程,源代码如下:

public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue) {// Executors.defaultThreadFactory() 为默认的线程创建工厂this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,Executors.defaultThreadFactory(), defaultHandler);
}
public static ThreadFactory defaultThreadFactory() {return new DefaultThreadFactory();
}
// 默认的线程创建工厂,需要实现 ThreadFactory 接口
static class DefaultThreadFactory implements ThreadFactory {private static final AtomicInteger poolNumber = new AtomicInteger(1);private final ThreadGroup group;private final AtomicInteger threadNumber = new AtomicInteger(1);private final String namePrefix;

DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() :
                              Thread.currentThread().getThreadGroup();
        namePrefix = “pool-” +
                      poolNumber.getAndIncrement() +
                     “-thread-”;
    }
    // 创建线程
    public Thread newThread(Runnable r) {
        Thread t = new Thread(group, r,
                              namePrefix + threadNumber.getAndIncrement(),
                              0);
        if (t.isDaemon()) 
            t.setDaemon(false); // 创建一个非守护线程
        if (t.getPriority() != Thread.NORM_PRIORITY)
            t.setPriority(Thread.NORM_PRIORITY); // 线程优先级设置为默认值
        return t;
    }
}

我们也可以自定义一个线程工厂,通过实现 ThreadFactory 接口来完成,这样就可以自定义线程的名称或线程执行的优先级了。

第 7 个参数:RejectedExecutionHandler 表示指定线程池的拒绝策略,当线程池的任务已经在缓存队列 workQueue 中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。

线程池的工作流程要从它的执行方法 execute() 说起,源码如下:

public void execute(Runnable command) {if (command == null)throw new NullPointerException();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);// 如果线程池的线程数为 0 时(当 corePoolSize 设置为 0 时会发生)else if (workerCountOf(recheck) == 0)addWorker(null, false); // 新建线程执行任务}// 核心线程都在忙且队列都已爆满,尝试新启动一个线程执行失败else if (!addWorker(command, false)) // 执行拒绝策略reject(command);
}

其中 addWorker(Runnable firstTask, boolean core) 方法的参数说明如下:

  • firstTask,线程应首先运行的任务,如果没有则可以设置为 null;

  • core,判断是否可以创建线程的阀值(最大值),如果等于 true 则表示使用 corePoolSize 作为阀值,false 则表示使用 maximumPoolSize 作为阀值。

考点分析

本课时的这道面试题考察的是你对于线程池和 ThreadPoolExecutor 的掌握程度,也属于 Java 的基础知识,几乎所有的面试都会被问到,其中线程池任务执行的主要流程,可以参考以下流程图:

与 ThreadPoolExecutor 相关的面试题还有以下几个:

  • ThreadPoolExecutor 的执行方法有几种?它们有什么区别?

  • 什么是线程的拒绝策略?

  • 拒绝策略的分类有哪些?

  • 如何自定义拒绝策略?

  • ThreadPoolExecutor 能不能实现扩展?如何实现扩展?

知识扩展

execute() VS submit()

execute() 和 submit() 都是用来执行线程池任务的,它们最主要的区别是,submit() 方法可以接收线程池执行的返回值,而 execute() 不能接收返回值。

来看两个方法的具体使用:

ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 10, 10L,TimeUnit.SECONDS, new LinkedBlockingQueue(20));
// execute 使用
executor.execute(new Runnable() {@Overridepublic void run() {System.out.println("Hello, execute.");}
});
// submit 使用
Future<String> future = executor.submit(new Callable<String>() {@Overridepublic String call() throws Exception {System.out.println("Hello, submit.");return "Success";}
});
System.out.println(future.get());

以上程序执行结果如下:

Hello, submit.
Hello, execute.
Success

从以上结果可以看出 submit() 方法可以配合 Futrue 来接收线程执行的返回值。它们的另一个区别是 execute() 方法属于 Executor 接口的方法,而 submit() 方法则是属于 ExecutorService 接口的方法,它们的继承关系如下图所示:

线程池的拒绝策略

当线程池中的任务队列已经被存满,再有任务添加时会先判断当前线程池中的线程数是否大于等于线程池的最大值,如果是,则会触发线程池的拒绝策略。

Java 自带的拒绝策略有 4 种:

  • AbortPolicy,终止策略,线程池会抛出异常并终止执行,它是默认的拒绝策略;

  • CallerRunsPolicy,把任务交给当前线程来执行;

  • DiscardPolicy,忽略此任务(最新的任务);

  • DiscardOldestPolicy,忽略最早的任务(最先加入队列的任务)。

例如,我们来演示一个 AbortPolicy 的拒绝策略,代码如下:

ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 10,TimeUnit.SECONDS, new LinkedBlockingQueue<>(2),new ThreadPoolExecutor.AbortPolicy()); // 添加 AbortPolicy 拒绝策略
for (int i = 0; i < 6; i++) {executor.execute(() -> {System.out.println(Thread.currentThread().getName());});
}

以上程序的执行结果:

pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-3
pool-1-thread-2
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.lagou.interview.ThreadPoolExample$$Lambda$1/1096979270@448139f0 rejected from java.util.concurrent.ThreadPoolExecutor@7cca494b[Running, pool size = 3, active threads = 3, queued tasks = 2, completed tasks = 0]at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)at com.lagou.interview.ThreadPoolExample.rejected(ThreadPoolExample.java:35)at com.lagou.interview.ThreadPoolExample.main(ThreadPoolExample.java:26)

可以看出当第 6 个任务来的时候,线程池则执行了 AbortPolicy  拒绝策略,抛出了异常。因为队列最多存储 2 个任务,最大可以创建 3 个线程来执行任务(2+3=5),所以当第 6 个任务来的时候,此线程池就“忙”不过来了。

自定义拒绝策略

自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可,如下代码所示:

ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 10,TimeUnit.SECONDS, new LinkedBlockingQueue<>(2),new RejectedExecutionHandler() {  // 添加自定义拒绝策略@Overridepublic void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {// 业务处理方法System.out.println("执行自定义拒绝策略");}});
for (int i = 0; i < 6; i++) {executor.execute(() -> {System.out.println(Thread.currentThread().getName());});
}

以上代码执行的结果如下:

执行自定义拒绝策略
pool-1-thread-2
pool-1-thread-3
pool-1-thread-1
pool-1-thread-1
pool-1-thread-2

可以看出线程池执行了自定义的拒绝策略,我们可以在 rejectedExecution 中添加自己业务处理的代码。

ThreadPoolExecutor 扩展

ThreadPoolExecutor 的扩展主要是通过重写它的 beforeExecute() 和 afterExecute() 方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间,如下代码所示:

public class ThreadPoolExtend {public static void main(String[] args) throws ExecutionException, InterruptedException {// 线程池扩展调用MyThreadPoolExecutor executor = new MyThreadPoolExecutor(2, 4, 10,TimeUnit.SECONDS, new LinkedBlockingQueue());for (int i = 0; i < 3; i++) {executor.execute(() -> {Thread.currentThread().getName();});}}/*** 线程池扩展*/static class MyThreadPoolExecutor extends ThreadPoolExecutor {// 保存线程执行开始时间private final ThreadLocal<Long> localTime = new ThreadLocal<>();public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,TimeUnit unit, BlockingQueue<Runnable> workQueue) {super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}/*** 开始执行之前* @param t 线程* @param r 任务*/@Overrideprotected void beforeExecute(Thread t, Runnable r) {Long sTime = System.nanoTime(); // 开始时间 (单位:纳秒)localTime.set(sTime);System.out.println(String.format("%s | before | time=%s",t.getName(), sTime));super.beforeExecute(t, r);}/*** 执行完成之后* @param r 任务* @param t 抛出的异常*/@Overrideprotected void afterExecute(Runnable r, Throwable t) {Long eTime = System.nanoTime(); // 结束时间 (单位:纳秒)Long totalTime = eTime - localTime.get(); // 执行总时间System.out.println(String.format("%s | after | time=%s | 耗时:%s 毫秒",Thread.currentThread().getName(), eTime, (totalTime / 1000000.0)));super.afterExecute(r, t);}}
}

以上程序的执行结果如下所示:

pool-1-thread-1 | before | time=4570298843700
pool-1-thread-2 | before | time=4570298840000
pool-1-thread-1 | after | time=4570327059500 | 耗时:28.2158 毫秒
pool-1-thread-2 | after | time=4570327138100 | 耗时:28.2981 毫秒
pool-1-thread-1 | before | time=4570328467800
pool-1-thread-1 | after | time=4570328636800 | 耗时:0.169 毫秒

小结

最后我们总结一下:线程池的使用必须要通过 ThreadPoolExecutor 的方式来创建,这样才可以更加明确线程池的运行规则,规避资源耗尽的风险。同时,也介绍了 ThreadPoolExecutor 的七大核心参数,包括核心线程数和最大线程数之间的区别,当线程池的任务队列没有可用空间且线程池的线程数量已经达到了最大线程数时,则会执行拒绝策略,Java 自动的拒绝策略有 4 种,用户也可以通过重写 rejectedExecution() 来自定义拒绝策略,我们还可以通过重写 beforeExecute() 和 afterExecute() 来实现 ThreadPoolExecutor 的扩展功能。


第05讲:ynchronized 和 ReentrantLock 的实现原理是什么?它们有什么区别?

在 JDK 1.5 之前共享对象的协调机制只有 synchronized 和 volatile,在 JDK 1.5 中增加了新的机制 ReentrantLock,该机制的诞生并不是为了替代 synchronized,而是在 synchronized 不适用的情况下,提供一种可以选择的高级功能。

我们本课时的面试题是,synchronized 和 ReentrantLock 是如何实现的?它们有什么区别?

典型回答

synchronized 属于独占式悲观锁,是通过 JVM 隐式实现的,synchronized 只允许同一时刻只有一个线程操作资源。

在 Java 中每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。

ReentrantLock 是 Lock 的默认实现方式之一,它是基于 AQS(Abstract Queued Synchronizer,队列同步器)实现的,它默认是通过非公平锁实现的,在它的内部有一个 state 的状态字段用于表示锁是否被占用,如果是 0 则表示锁未被占用,此时线程就可以把 state 改为 1,并成功获得锁,而其他未获得锁的线程只能去排队等待获取锁资源。

synchronized 和 ReentrantLock 都提供了锁的功能,具备互斥性和不可见性。在 JDK 1.5 中 synchronized 的性能远远低于  ReentrantLock,但在 JDK 1.6 之后  synchronized 的性能略低于  ReentrantLock,它的区别如下:

  • synchronized 是 JVM 隐式实现的,而 ReentrantLock 是 Java 语言提供的 API;
  • ReentrantLock 可设置为公平锁,而 synchronized 却不行;
  • ReentrantLock 只能修饰代码块,而 synchronized 可以用于修饰方法、修饰代码块等;
  • ReentrantLock 需要手动加锁和释放锁,如果忘记释放锁,则会造成资源被永久占用,而 synchronized 无需手动释放锁;
  • ReentrantLock 可以知道是否成功获得了锁,而 synchronized  却不行。

考点分析

synchronized 和 ReentrantLock 是比线程池还要高频的面试问题,因为它包含了更多的知识点,且涉及到的知识点更加深入,对面试者的要求也更高,前面我们简要地介绍了 synchronized 和 ReentrantLock 的概念及执行原理,但很多大厂会更加深入的追问更多关于它们的实现细节,比如:

  • ReentrantLock 的具体实现细节是什么?
  • JDK 1.6 时锁做了哪些优化?

知识扩展

ReentrantLock 源码分析

本课时从源码出发来解密 ReentrantLock 的具体实现细节,首先来看 ReentrantLock 的两个构造函数:

public ReentrantLock() {sync = new NonfairSync(); // 非公平锁
}
public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();
}

无参的构造函数创建了一个非公平锁,用户也可以根据第二个构造函数,设置一个 boolean 类型的值,来决定是否使用公平锁来实现线程的调度。

公平锁 VS 非公平锁

公平锁的含义是线程需要按照请求的顺序来获得锁;而非公平锁则允许“插队”的情况存在,所谓的“插队”指的是,线程在发送请求的同时该锁的状态恰好变成了可用,那么此线程就可以跳过队列中所有排队的线程直接拥有该锁。

而公平锁由于有挂起和恢复所以存在一定的开销,因此性能不如非公平锁,所以 ReentrantLock 和 synchronized 默认都是非公平锁的实现方式。

ReentrantLock 是通过 lock() 来获取锁,并通过 unlock() 释放锁,使用代码如下:

Lock lock = new ReentrantLock();
try {// 加锁lock.lock();//......业务处理
} finally {// 释放锁lock.unlock();
}

ReentrantLock 中的 lock() 是通过 sync.lock() 实现的,但 Sync 类中的 lock() 是一个抽象方法,需要子类 NonfairSync 或 FairSync 去实现,NonfairSync 中的 lock() 源码如下:

final void lock() {if (compareAndSetState(0, 1))// 将当前线程设置为此锁的持有者setExclusiveOwnerThread(Thread.currentThread());elseacquire(1);
}

FairSync 中的 lock() 源码如下:

final void lock() {acquire(1);
}

可以看出非公平锁比公平锁只是多了一行 compareAndSetState 方法,该方法是尝试将 state 值由 0 置换为 1,如果设置成功的话,则说明当前没有其他线程持有该锁,不用再去排队了,可直接占用该锁,否则,则需要通过 acquire 方法去排队。

acquire 源码如下:

public final void acquire(int arg) {if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();
}

tryAcquire 方法尝试获取锁,如果获取锁失败,则把它加入到阻塞队列中,来看 tryAcquire 的源码:

protected final boolean tryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();if (c == 0) {// 公平锁比非公平锁多了一行代码 !hasQueuedPredecessors() if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) { //尝试获取锁setExclusiveOwnerThread(current); // 获取成功,标记被抢占return true;}}else if (current == getExclusiveOwnerThread()) {int nextc = c + acquires;if (nextc < 0)throw new Error("Maximum lock count exceeded");setState(nextc); // set state=state+1return true;}return false;
}

对于此方法来说,公平锁比非公平锁只多一行代码 !hasQueuedPredecessors(),它用来查看队列中是否有比它等待时间更久的线程,如果没有,就尝试一下是否能获取到锁,如果获取成功,则标记为已经被占用。

如果获取锁失败,则调用 addWaiter 方法把线程包装成 Node 对象,同时放入到队列中,但 addWaiter 方法并不会尝试获取锁,acquireQueued 方法才会尝试获取锁,如果获取失败,则此节点会被挂起,源码如下:

/*** 队列中的线程尝试获取锁,失败则会被挂起*/
final boolean acquireQueued(final Node node, int arg) {boolean failed = true; // 获取锁是否成功的状态标识try {boolean interrupted = false; // 线程是否被中断for (;;) {// 获取前一个节点(前驱节点)final Node p = node.predecessor();// 当前节点为头节点的下一个节点时,有权尝试获取锁if (p == head && tryAcquire(arg)) {setHead(node); // 获取成功,将当前节点设置为 head 节点p.next = null; // 原 head 节点出队,等待被 GCfailed = false; // 获取成功return interrupted;}// 判断获取锁失败后是否可以挂起if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())// 线程若被中断,返回 trueinterrupted = true;}} finally {if (failed)cancelAcquire(node);}
}

该方法会使用 for(;;) 无限循环的方式来尝试获取锁,若获取失败,则调用 shouldParkAfterFailedAcquire 方法,尝试挂起当前线程,源码如下:

/*** 判断线程是否可以被挂起*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {// 获得前驱节点的状态int ws = pred.waitStatus;// 前驱节点的状态为 SIGNAL,当前线程可以被挂起(阻塞)if (ws == Node.SIGNAL)return true;if (ws > 0) { do {// 若前驱节点状态为 CANCELLED,那就一直往前找,直到找到一个正常等待的状态为止node.prev = pred = pred.prev;} while (pred.waitStatus > 0);// 并将当前节点排在它后边pred.next = node;} else {// 把前驱节点的状态修改为 SIGNALcompareAndSetWaitStatus(pred, ws, Node.SIGNAL);}return false;
}

线程入列被挂起的前提条件是,前驱节点的状态为 SIGNAL,SIGNAL 状态的含义是后继节点处于等待状态,当前节点释放锁后将会唤醒后继节点。所以在上面这段代码中,会先判断前驱节点的状态,如果为 SIGNAL,则当前线程可以被挂起并返回 true;如果前驱节点的状态 >0,则表示前驱节点取消了,这时候需要一直往前找,直到找到最近一个正常等待的前驱节点,然后把它作为自己的前驱节点;如果前驱节点正常(未取消),则修改前驱节点状态为 SIGNAL。

到这里整个加锁的流程就已经走完了,最后的情况是,没有拿到锁的线程会在队列中被挂起,直到拥有锁的线程释放锁之后,才会去唤醒其他的线程去获取锁资源,整个运行流程如下图所示:

unlock 相比于 lock 来说就简单很多了,源码如下:

public void unlock() {sync.release(1);
}
public final boolean release(int arg) {// 尝试释放锁if (tryRelease(arg)) {// 释放成功Node h = head;if (h != null && h.waitStatus != 0)unparkSuccessor(h);return true;}return false;
}

锁的释放流程为,先调用 tryRelease 方法尝试释放锁,如果释放成功,则查看头结点的状态是否为 SIGNAL,如果是,则唤醒头结点的下个节点关联的线程;如果释放锁失败,则返回 false。

tryRelease 源码如下:

/*** 尝试释放当前线程占有的锁*/
protected final boolean tryRelease(int releases) {int c = getState() - releases; // 释放锁后的状态,0 表示释放锁成功// 如果拥有锁的线程不是当前线程的话抛出异常if (Thread.currentThread() != getExclusiveOwnerThread())throw new IllegalMonitorStateException();boolean free = false;if (c == 0) { // 锁被成功释放free = true;setExclusiveOwnerThread(null); // 清空独占线程}setState(c); // 更新 state 值,0 表示为释放锁成功return free;
}

在 tryRelease 方法中,会先判断当前的线程是不是占用锁的线程,如果不是的话,则会抛出异常;如果是的话,则先计算锁的状态值 getState() - releases 是否为 0,如果为 0,则表示可以正常的释放锁,然后清空独占的线程,最后会更新锁的状态并返回执行结果。

JDK 1.6 锁优化

自适应自旋锁

JDK 1.5 在升级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化上下了很大功夫,比如实现了自适应式自旋锁、锁升级等。

JDK 1.6 引入了自适应式自旋锁意味着自旋的时间不再是固定的时间了,比如在同一个锁对象上,如果通过自旋等待成功获取了锁,那么虚拟机就会认为,它下一次很有可能也会成功 (通过自旋获取到锁),因此允许自旋等待的时间会相对的比较长,而当某个锁通过自旋很少成功获得过锁,那么以后在获取该锁时,可能会直接忽略掉自旋的过程,以避免浪费 CPU 的资源,这就是自适应自旋锁的功能。

锁升级

锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。

偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到另一个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。但如果在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。

轻量锁是相对于重量锁而言的,在 JDK 1.6 之前,synchronized 是通过操作系统的互斥量(mutex lock)来实现的,这种实现方式需要在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为重量锁。

轻量锁是通过比较并交换(CAS,Compare and Swap)来实现的,它对比的是线程和对象的 Mark Word(对象头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。

小结

本课时首先讲了 synchronized 和 ReentrantLock 的实现过程,然后讲了 synchronized 和 ReentrantLock 的区别,最后通过源码的方式讲了 ReentrantLock 加锁和解锁的执行流程。接着又讲了 JDK 1.6 中的锁优化,包括自适应式自旋锁的实现过程,以及 synchronized 的三种锁状态和锁升级的执行流程。

synchronized 刚开始为偏向锁,随着锁竞争越来越激烈,会升级为轻量级锁和重量级锁。如果大多数锁被不同的线程所争抢就不建议使用偏向锁了。


第06讲:谈谈你对锁的理解?如何手动模拟一个死锁?

在并发编程中有两个重要的概念:线程和锁,多线程是一把双刃剑,它在提高程序性能的同时,也带来了编码的复杂性,对开发者的要求也提高了一个档次。而锁的出现就是为了保障多线程在同时操作一组资源时的数据一致性,当我们给资源加上锁之后,只有拥有此锁的线程才能操作此资源,而其他线程只能排队等待使用此锁。当然,在所有的面试中也都少不了关于“锁”方面的相关问题。

我们本课时的面试题是,如何手动模拟一个死锁?谈谈你对锁的理解。

典型回答

死锁是指两个线程同时占用两个资源,又在彼此等待对方释放锁资源,如下图所示:

死锁的代码演示如下:

import java.util.concurrent.TimeUnit;

public class LockExample {
    public static void main(String[] args) {
        deadLock(); // 死锁
    }

/**
      死锁
     
/
    private static void deadLock() {
        Object lock1 = new Object();
        Object lock2 = new Object();
        // 线程一拥有 lock1 试图获取 lock2
        new Thread(() -> {
            synchronized (lock1) {
                System.out.println(“获取 lock1 成功”);
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 试图获取锁 lock2
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }).start();
        // 线程二拥有 lock2 试图获取 lock1
        new Thread(() -> {
            synchronized (lock2) {
                System.out.println(“获取 lock2 成功”);
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 试图获取锁 lock1
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }).start();
    }
}

以上程序执行结果如下:

获取 lock1 成功
获取 lock2 成功

可以看出当我们使用线程一拥有锁 lock1 的同时试图获取 lock2,而线程二在拥有 lock2 的同时试图获取 lock1,这样就会造成彼此都在等待对方释放资源,于是就形成了死锁

锁是指在并发编程中,当有多个线程同时操作一个资源时,为了保证数据操作的正确性,我们需要让多线程排队一个一个地操作此资源,而这个过程就是给资源加锁和释放锁的过程,就好像去公共厕所一样,必须一个一个排队使用,并且在使用时需要锁门和开门一样。

考点分析

锁的概念不止出现在 Java 语言中,比如乐观锁和悲观锁其实很早就存在于数据库中了。锁的概念其实不难理解,但要真正地了解锁的原理和实现过程,才能打动面试官。

和锁相关的面试问题,还有以下几个:

  • 什么是乐观锁和悲观锁?它们的应用都有哪些?乐观锁有什么问题?

  • 什么是可重入锁?用代码如何实现?它的实现原理是什么?

  • 什么是共享锁和独占锁?

知识扩展

1. 悲观锁和乐观锁

悲观锁指的是数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。

我们来看一下悲观锁的实现流程,以 synchronized 为例,代码如下:

public class LockExample {public static void main(String[] args) {synchronized (LockExample.class) {System.out.println("lock");}}
}

我们使用反编译工具查到的结果如下:

Compiled from "LockExample.java"
public class com.lagou.interview.ext.LockExample {public com.lagou.interview.ext.LockExample();Code:0: aload_01: invokespecial #1                  // Method java/lang/Object."<init>":()V4: returnpublic static void main(java.lang.String[]);Code:0: ldc           #2                  // class com/lagou/interview/ext/LockExample2: dup3: astore_14: monitorenter // 加锁5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;8: ldc           #4                  // String lock10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V13: aload_114: monitorexit // 释放锁15: goto          2318: astore_219: aload_120: monitorexit21: aload_222: athrow23: returnException table:from    to  target type5    15    18   any18    21    18   any
}

可以看出被 synchronized 修饰的代码块,在执行之前先使用 monitorenter 指令加锁,然后在执行结束之后再使用 monitorexit 指令释放锁资源,在整个执行期间此代码都是锁定的状态,这就是典型悲观锁的实现流程

乐观锁和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测。

Java 中的乐观锁大部分都是通过 CAS(Compare And Swap,比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。

CAS 可能会造成 ABA 的问题,ABA 问题指的是,线程拿到了最初的预期原值 A,然而在将要进行 CAS 的时候,被其他线程抢占了执行权,把此值从 A 变成了 B,然后其他线程又把此值从 B 变成了 A,然而此时的 A 值已经并非原来的 A 值了,但最初的线程并不知道这个情况,在它进行 CAS 的时候,只对比了预期原值为 A 就进行了修改,这就造成了 ABA 的问题。

以警匪剧为例,假如某人把装了 100W 现金的箱子放在了家里,几分钟之后要拿它去赎人,然而在趁他不注意的时候,进来了一个小偷,用空箱子换走了装满钱的箱子,当某人进来之后看到箱子还是一模一样的,他会以为这就是原来的箱子,就拿着它去赎人了,这种情况肯定有问题,因为箱子已经是空的了,这就是 ABA 的问题。

ABA 的常见处理方式是添加版本号,每次修改之后更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了 ABA 的问题。

JDK 在 1.5 时提供了 AtomicStampedReference 类也可以解决 ABA 的问题,此类维护了一个“版本号” Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。

相关源码如下:

public class AtomicStampedReference<V> {private static class Pair<T> {final T reference;final int stamp; // “版本号”private Pair(T reference, int stamp) {this.reference = reference;this.stamp = stamp;}static <T> Pair<T> of(T reference, int stamp) {return new Pair<T>(reference, stamp);}}// 比较并设置public boolean compareAndSet(V   expectedReference,V   newReference,int expectedStamp, // 原版本号int newStamp) { // 新版本号Pair<V> current = pair;returnexpectedReference == current.reference &&expectedStamp == current.stamp &&((newReference == current.reference &&newStamp == current.stamp) ||casPair(current, Pair.of(newReference, newStamp)));}//.......省略其他源码
}

可以看出它在修改时会进行原值比较和版本号比较,当比较成功之后会修改值并修改版本号。

小贴士:乐观锁有一个优点,它在提交的时候才进行锁定的,因此不会造成死锁。

2. 可重入锁

可重入锁也叫递归锁,指的是同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁。

下面我们用 synchronized 来演示一下什么是可重入锁,代码如下:

public class LockExample {public static void main(String[] args) {reentrantA(); // 可重入锁}/*** 可重入锁 A 方法*/private synchronized static void reentrantA() {System.out.println(Thread.currentThread().getName() + ":执行 reentrantA");reentrantB();}/*** 可重入锁 B 方法*/private synchronized static void reentrantB() {System.out.println(Thread.currentThread().getName() + ":执行 reentrantB");}
}

以上代码的执行结果如下:

main:执行 reentrantA
main:执行 reentrantB

从结果可以看出 reentrantA 方法和 reentrantB 方法的执行线程都是“main” ,我们调用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。

可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为 0,当被线程占用和重入时分别加 1,当锁被释放时计数器减 1,直到减到 0 时表示此锁为空闲状态。

3. 共享锁和独占锁

只能被单线程持有的锁叫独占锁,可以被多线程持有的锁叫共享锁

独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 synchronized 就是独占锁,而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。

独占锁可以理解为悲观锁,当每次访问资源时都要加上互斥锁,而共享锁可以理解为乐观锁,它放宽了加锁的条件,允许多线程同时访问该资源。

小结

本课时我们讲了悲观锁和乐观锁,其中悲观锁的典型应用为 synchronized,它的特性为独占式互斥锁;而乐观锁相比于悲观锁而言,拥有更好的性能,但乐观锁可能会导致 ABA 的问题,常见的解决方案是添加版本号来防止 ABA 问题的发生。同时,还讲了可重入锁,在 Java 中,synchronized 和 ReentrantLock 都是可重入锁。最后,讲了独占锁和共享锁,其中独占锁可以理解为悲观锁,而共享锁可以理解为乐观锁。


java大厂技术面试第二课 多线程和锁相关推荐

  1. java大厂技术面试第八课 nginx/docker/tcp等

    第31讲:如何保证接口的幂等性?常见的实现方案有哪些? 幂等性问题是面试中常见的面试问题,也是分布式系统最常遇到的问题之一.在说幂等性之前,我们先来看一种情况,假如老王在某电商平台进行购物,付款的时候 ...

  2. 互联网大厂技术面试内幕@霞落满天

    很多求职者往往并非因为技术不好,而是没有掌握面试的技巧导致不能把握机会,本课程的目的就是本课程先通过比较真实的好简历和不好的简历让大家明白自己的简历有哪些问题,事实上简历是大厂的敲门砖,非常重要,很多 ...

  3. 开篇词:大厂技术面试“潜规则”

    你好,我是你的 Java 面试课老师,拥有 10 多年大型系统设计.开发和调优经验,热衷于技术分享,是阿里云社区的认证专家,腾讯社区年度最佳作者. 喜欢分享,跟我的个人经历有关:因为曾经吃过亏,所以想 ...

  4. Java后端技术面试汇总(第一套)

    面试汇总,整理一波,doc文档可点击[ 此处下载] 1.基础篇 1.1.Java基础 • 面向对象的特征:继承.封装和多态 • final, finally, finalize 的区别 • Excep ...

  5. 阿里、腾讯等大厂技术面试题目汇总

    Github地址:https://github.com/0voice/interview_internal_reference 每一个程序员估计都想挤进大厂吧,但是大厂不是你想进就能进,竞争程度堪比高 ...

  6. 开源项目推荐!2019阿里,腾讯,百度,美团,头条等大厂技术面试题目汇总

    每一个程序员估计都想挤进大厂吧,但是大厂不是你想进就能进,竞争程度堪比高考,你需要通过层层考验,虽说不需要三年高考五年模拟,但是刷笔试习题肯定是少不了的 像<程序员面试宝典>.<C和 ...

  7. 如何准备大厂技术面试?偷学「大师兄」秘籍!

    春暖花开的时节,各大厂的春招面试正如火如荼地进行着.相信同学们也在面试这块做了许多准备,「小新」也不例外,扬言要进入某巴巴,从此走上人生巅峰,那么,参加大厂的面试需要注意些什么呢? 「小新」从来不打无 ...

  8. Java第二课(多线程,jdbc,io)

    Java基础二 1.多线程 01简介 02线程实现(重点) 三种创建方式 继承Thread和实现Runnable接口对比: 静态代理模式 线程状态 线程方法: 线程停止 线程休眠 线程礼让 线程强制执 ...

  9. Java大厂技术文档:Redis+Nginx+设计模式+Spring全家桶+Dubbo精选

    最近花了很长的时间去搜罗整理Java核心技术好文,我把每个Java核心技术的优选文章都整理成了一个又一个的文档.今天就把这些东西分享给老铁们,也能为老铁们省去不少麻烦,想学什么技能了,遇到哪方面的问题 ...

最新文章

  1. Ubuntu18.04 + Nvida GTX 1660ti显卡 驱动安装
  2. Jupiter:Facebook的高性能job-matching服务
  3. android sdk 源码解析
  4. python—多线程之数据混乱问题
  5. 配置_DruidDataSource参考配置
  6. [Grid Layout] Place grid items on a grid using grid-column and grid-row
  7. laravel windos 无法生成 appkey 的问题解决方法
  8. PyTorch | 通过torch.arange创建等差数列张量 | torch.arange()如何使用?torch.arange()使用方法 torch.arange()举例说明
  9. 将您的SQL Server工作负载迁移到PostgreSQL –第3部分
  10. apachecommon连接mysql_使用 apache common dbcp +common pool+mysql连接无效的问题
  11. ContOS安装配置MySQL,redis
  12. 基于java的房屋销售管理系统设计(含源文件)
  13. 离线语音识别与在线语音识别的区别
  14. 推荐这3款图片流动特效神器,一键即可让照片“动”起来
  15. 监控摄像头角度范围计算方法
  16. java pdf转png
  17. Scala的下载与安装
  18. 7. 【可编程定时器8253】:外部引脚、内部结构特点、计数启动方式、6种工作方式、控制字格式、应用
  19. 2020北航计算机夏令营
  20. 计算机网络实验三——IP网络规划与路由设计

热门文章

  1. 图像的 matlab代码,常用的一些图像处理Matlab源代码
  2. 网络运维基础知识手册
  3. 2018-2019年世界经济危机是中国崛起最大的机遇!
  4. python ros gps转xyz坐标系
  5. oracle 存储过程教程
  6. 验证码登录开发----手机验证码登录
  7. 【微服务】链路追踪 jaeger
  8. android 断电自动关机,Android 断开电源10秒后自动关机
  9. babylonjs 分部加载模型_基于babylon3D模型研究3D骨骼动画(1)
  10. diyUpload - jQuery多张图片批量上传插件