[八股大全]多线程篇

前言:这是我的八股笔记专栏的第8篇文章,觉得有用的建议给我的专栏点个订阅支持一下。

0.并发编程的3个重要特性

Java并发编程三大特性

  • 原子性

  • 可见性

  • 有序性

(1)原子性

一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

若不保证原子性,可能出现订单超卖问题

解决方案:

1.synchronized:同步加锁

2.JUC里面的lock:加锁

(2)内存可见性

内存可见性:让一个线程对共享变量的修改对另一个线程可见

解决方案:

  • synchronized

  • volatile(推荐)

  • LOCK

(3)有序性

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

解决方案:

  • volatile

1.线程进程

0.Thread的join()方法

1.join()的作用

join()是 Thread 类中的一个方法,当我们需要让线程按照自己指定的顺序执行的时候,就可以利用这个方法。Thread.join()方法表示调用此方法的线程被阻塞,仅当该方法完成以后,才能继续运行」

❝ 作用于 main( )主线程时,会等待其他线程结束后再结束主线程。 ❞

public static void main(String[] args){
    System.out.println("MainThread run start.");

    //启动一个子线程
    Thread threadA = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("threadA run start.");
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println("threadA run finished.");
        }
    });
//没加join()的情况
    threadA.start();

    System.out.println("MainThread join before");
    System.out.println("MainThread run finished.");
}
//输出
MainThread run start.
threadA run start.
MainThread join before
MainThread run finished.
threadA run finished.
因为上述子线程执行时间相对较长,所以是在主线程执行完毕之后才结束。
//加上join()方法的情况
threadA.start();

    System.out.println("MainThread join before");
    try {
        threadA.join();    //调用join()
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("MainThread run finished.");
//输出
MainThread run start.
threadA run start.
MainThread join before
threadA run finished.
MainThread run finished.
对子线程threadA使用了join()方法之后,我们发现主线程会等待子线程执行完成之后才往后执行。

2.join()的原理

首先join() 是一个synchronized方法, 里面调用了wait(),这个过程的目的是让持有这个同步锁的线程进入等待,那么谁持有了这个同步锁呢?答案是主线程,因为主线程调用了threadA.join()方法,相当于在threadA.join()代码这块写了一个同步代码块,谁去执行了这段代码呢,是主线程,所以主线程被wait()了。然后在子线程threadA执行完毕之后,JVM会调用lock.notify_all(thread);唤醒持有threadA这个对象锁的线程,也就是主线程,会继续执行。

1. 线程和进程是什么及其区别?

什么是进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

什么是线程?

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
  • 线程作为最小调度单位,进程作为资源分配的最小单位。

2 并行和并发有什么区别?

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

并行是同一时间动手做多件事情的能力,比如4核CPU同时执行4个线程

3.同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

3 创建线程的四种方式

共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程

详细创建方式参考下面代码:

继承Thread类

public class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("MyThread...run...");
    }

    
    public static void main(String[] args) {

        // 创建MyThread对象
        MyThread t1 = new MyThread() ;
        MyThread t2 = new MyThread() ;

        // 调用start方法启动线程
        t1.start();
        t2.start();

    }
    
}

实现runnable接口

public class MyRunnable implements Runnable{

    @Override
    public void run() {
        System.out.println("MyRunnable...run...");
    }

    public static void main(String[] args) {

        // 创建MyRunnable对象
        MyRunnable mr = new MyRunnable() ;

        // 创建Thread对象
        Thread t1 = new Thread(mr) ;
        Thread t2 = new Thread(mr) ;

        // 调用start方法启动线程
        t1.start();
        t2.start();

    }

}

实现Callable接口

public class MyCallable implements Callable<String> {
//MyCallable类实现了Callable<String>接口,该接口是一个泛型接口,指定了call()方法的返回类型为String
    @Override
    public String call() throws Exception {//call方法可以抛出异常
        System.out.println("MyCallable...call...");
        return "OK";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        // 创建MyCallable对象
        MyCallable mc = new MyCallable() ;

        //创建FutureTask<String>对象ft,并将mc作为参数传入构造方法,用于封装可调用对象
        FutureTask<String> ft = new FutureTask<String>(mc) ;
        //通过将Callable对象封装在FutureTask中,可以在多线程环境下执行任务,并获取任务执行结果。

        // 创建Thread对象
        Thread t1 = new Thread(ft) ;
        Thread t2 = new Thread(ft) ;

        // 调用start方法启动线程
        t1.start();

        //调用ft.get()方法获取执行结果,此方法会阻塞当前线程直到结果返回。
        String result = ft.get();

        // 输出
        System.out.println(result);

    }
}
//输出结果
MyCallable...call...
OK

线程池创建线程

public class MyExecutors implements Runnable{

    @Override
    public void run() {
        System.out.println("MyRunnable...run...");
    }

    public static void main(String[] args) {

        // 创建线程池对象。获取ExecutorService实例,生产禁用,需要手动创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(3);
        //通过threadPool.submit(new MyExecutors())来向线程池提交任务。
        threadPool.submit(new MyExecutors()) ;

        //调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
        threadPool.shutdown();

    }

}

runnable 和 callable 两个接口创建线程有什么不同呢?

Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息

4. 线程的 run()和 start()有什么区别?

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

5.线程的生命周期和状态

初始(NEW):线程被构建,还没有调用 start()。

运行(RUNNABLE):包括操作系统的就绪和运行两种状态。

阻塞(BLOCKED):一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放CPU,不释放内存。

等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。

超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。

终止(TERMINATED):表示该线程已经执行完毕。

6 线程状态之间是如何变化的

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

7 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

代码举例:

为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成

public class JoinTest {

    public static void main(String[] args) {

        // 创建线程对象
        Thread t1 = new Thread(() -> {
            System.out.println("t1");
        }) ;

        Thread t2 = new Thread(() -> {
            try {
                t1.join();                          // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t2");
        }) ;


        Thread t3 = new Thread(() -> {
            try {
                t2.join();                              // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t3");
        }) ;

        // 启动线程
        t1.start();
        t2.start();
        t3.start();

    }

}

7.什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

8.线程死锁

0.什么是线程死锁?

死锁:线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。

package com.itheima.basic;

import static java.lang.Thread.sleep;

public class Deadlock {
	//t1 线程获得A资源,接下来想获取B资源
	//t2 线程获得B资源,接下来想获取A资源 
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println(Thread.currentThread() + "get resource1");
                    
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (B) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println(Thread.currentThread() + "get resource1");
                   
                }
            }
        }, "t2");
        t1.start();
        t2.start();
    }
}

1.线程死锁怎么产生?怎么避免?

死锁产生的四个必要条件

  • 互斥:一个资源每次只能被一个进程使用
  • 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
  • 不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
  • 循环等待:进程之间循环等待着资源

避免死锁的方法

  • 互斥条件不能破坏,因为加锁就是为了保证互斥
  • 一次性申请所有的资源,避免线程占有资源而且在等待其他资源
  • 占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
  • 按序申请资源

2.如何进行死锁诊断?

通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

9. notify()和 notifyAll()有什么区别?

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

10. 在 java 中 wait 和 sleep 方法的不同?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
  • 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出InterruptedException

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

代码示例:

public class WaitSleepCase {

    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        sleeping();
    }

    private static void illegalWait() throws InterruptedException {
        LOCK.wait();
    }

    private static void waiting() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                try {
                    get("t").debug("waiting...");
                    LOCK.wait(5000L);
                } catch (InterruptedException e) {
                    get("t").debug("interrupted...");
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        Thread.sleep(100);
        synchronized (LOCK) {
            main.debug("other...");
        }

    }

    private static void sleeping() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                try {
                    get("t").debug("sleeping...");
                    Thread.sleep(5000L);
                } catch (InterruptedException e) {
                    get("t").debug("interrupted...");
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        Thread.sleep(100);
        synchronized (LOCK) {
            main.debug("other...");
        }
    }
}

11. 如何停止一个正在运行的线程?

有3种方式可以停止线程

  • 使用stop方法

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止

  • 使用interrupt方法中断线程

代码参考如下:

0.使用stop方法

虽然Thread类提供了一个stop()方法来强行终止线程,但这个方法已经被弃用,因为它是不安全的。使用stop()方法可能会导致资源无法正确释放,或者导致应用程序状态不一致。

1.使用退出标志,使线程正常退出

public class MyInterrupt1 extends Thread {

    volatile boolean flag = false ;     // 线程执行的退出标记

    @Override
    public void run() {
        while(!flag) {
            System.out.println("MyThread...run...");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        // 创建MyThread对象
        MyInterrupt1 t1 = new MyInterrupt1() ;
        t1.start();

        // 主线程休眠6秒
        Thread.sleep(6000);

        // 更改标记为true
        t1.flag = true ;

    }
}

2.使用interrupt方法中断线程

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

public class InterruptExample {

    private static class MyThread1 extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                System.out.println("Thread run");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new MyThread1();
    thread1.start();
    thread1.interrupt();
    System.out.println("Main run");
}
//输出结果
Main run
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at InterruptExample.lambda$main$0(InterruptExample.java:5)
    at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。

public class InterruptExample {

    private static class MyThread2 extends Thread {
        @Override
        public void run() {
            while (!Thread.currentThread().interrupted()) {
                // ..
            }
            System.out.println("Thread end");
        }
    }
}
public static void main(String[] args) throws InterruptedException {
    Thread thread2 = new MyThread2();
    thread2.start();
    thread2.interrupt();
}
//输出结果
Thread end

12.线程间通信方式

1、使用 Object 类的 wait()/notify()。Object 类提供了线程间通信的方法:wait()notify()notifyAll(),它们是多线程通信的基础。其中,wait/notify 必须配合 synchronized 使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify(),notify并不释放锁,只是告诉调用过wait()的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait() 的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。

2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。

3、使用JUC工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。

4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字

13.进程间的通讯方式

进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。Linux 内核提供了多种进程间通信机制,包括管道、消息队列、共享内存、信号量和 PV 操作、SysV 和 POSIX 信号量以及套接字(Socket)。

  • 管道:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 消息队列:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它可以被用来实现高速数据传输和大量数据的传输,因此它往往用于进程间的数据共享或者是进程间的通信。
  • 信号量和 PV 操作:主要作为互斥锁。
  • SysV 和 POSIX 信号量:包括计数器和 semaphore 结构体。
  • 套接字(Socket):主要用于不同主机间的进程通信。

2.线程中并发锁

1. 锁的分类

-1.可中断锁和不可中断锁

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

0.可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

1. 公平锁与非公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

synchronized是非公平锁,Lock默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

2. 共享式与独占式锁

  • 共享锁:一把锁可以被多个线程同时获得。比如semaphore信号量
  • 独占锁:一把锁只能被一个线程获得。比如sychronized,reentrantlock

3.悲观锁与乐观锁

1.两者的区别

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证数据是否被其它线程修改。乐观锁最常见的实现就是CAS

适用场景:

  • 悲观锁适合写操作多的场景。
  • 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。

2.乐观锁是怎么实现的?

可以用版本号机制或CAS算法。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 versi 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 值相等时才更新,否则重试更新操作,直到更新成功。

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

内容包含: 1.八股大全:多一句没有少一句不行的最精简八股整理,完全可以应付校招八股拷打! 2.速成项目话术:目前有魔改苍穹外卖项目话术(额外扩展了很多技术亮点),能速成拿去面试,后面会更新魔改黑马点评、商城项目等等热门高质量项目话术 3.智力题超详细题解汇总; 4.面试时非技术问题话术整理,绝对震惊面试官一年; 5.算法lc hot100全题系列题解:绝对通俗易懂。 会慢慢涨价,欢迎订阅!

全部评论
牛客是不是限我流了😁是不支持发长文吗?
2 回复 分享
发布于 2024-05-27 10:44 湖北
给我的专栏点点订阅让我赚点零花钱吧
2 回复 分享
发布于 2024-05-26 22:52 湖北
11.11日已更新,订阅我专栏后可进入我的专栏里用md格式侧边栏看该笔记体验感更好!
1 回复 分享
发布于 2024-11-11 18:06 湖南
我觉得这个八股专栏做得很棒 懒人福音 支持一手 加油⛽
1 回复 分享
发布于 2024-06-08 18:02 江苏
50买的降到30了
点赞 回复 分享
发布于 2024-11-12 22:10 北京
篮子哥去华子了么
点赞 回复 分享
发布于 2024-05-28 21:17 重庆

相关推荐

双非阴暗爬行:我来看看笑死我了,这名字取得好想笑(没有不好的意思)
点赞 评论 收藏
分享
抱抱碍事梨a:三点建议,第一点是建议再做一个项目,把自我介绍部分顶了,第二点是中南大学加黑加粗,第三点是建议加v详细交流
点赞 评论 收藏
分享
评论
21
52
分享

创作者周榜

更多
牛客网
牛客企业服务