1. 开篇词

说到并发编程,可能大家脑海中的第一印象会是 Thread、多线程、JUC、线程池、ThreadLocal 等等内容。确实,并发编程是 Java 编程中不可或缺的一部分,掌握并发编程的核心技术,在求职面试中会是摧城拔寨的利器。而今天将要跟大家一起聊聊的是:并发编程的基石——Thread 类的工作原理。

事实上,在笔者回忆关于 Thread 类的核心 API 以及对应的线程状态转换关系时,总觉得印象有一些模糊,故此才有这篇文章。本文的核心议题是 Thread 类,由此延伸出诸多议题,例如:进程与线程、线程状态及生命周期、Thread API 的用法等等。

2. 进程与线程

首先,有必要介绍一下进程与线程,以及它们之间的区别与关系。

进程是操作系统分配资源的基本单位,比如我们在启动一个 main 此时就启动了一个 JVM 进程。

线程则是比进程纬度更小的单位,它是 CPU 分配的基本单位(因为真正占用运行的就是线程),比如启动一个 main 方法后它所在的线程就属于这个 JVM 进程的一个线程,它的名字叫主线程。一个进程可以有一个或多个线程,同一个进程中的各个线程之间共享进程的内存空间。

进程与线程之间的区别如下:

  • 进程是操作系统分配资源的最小单位,而线程是 CPU 分配(程序执行)的最小单位
  • 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  • 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间
  • 调度和切换:线程上下文切换比进程上下文切换要快得多

3. Thread 常用 API

鉴于笔者此前对 Thread 类核心 API 知之甚少,对其原理不甚了解,于是本小节主要内容就是介绍 Thread 类核心 API 的用法、意义及对线程状态的影响等内容。

3.1 创建线程任务

在调用 Thread 类的 API 之前,需要先创建线程对象,这很简单,只需要 new Thread() 就可以了。但是事实上,如果只通过 new 创建线程并未做其他任何操作,那么这个线程将不会执行任何业务逻辑。

所以我们需要通过另外的手段为线程指定其执行的业务逻辑,那么问题来了:创建线程任务有几种形式?

一般来说,我们认为有三种形式:继承 Thread 类、实现 Runnable 接口以及实现 Callable 接口,下面一一进行详述。

3.1.1 继承 Thread 类

创建一个类,让它继承 Thread 类并覆盖 run 方法,run 方法中指定了线程执行的业务逻辑。这样在以后创建线程时可以直接实例化该类即可,在启动线程后程序会自动去执行覆盖的 run 方法逻辑。

public class CreateThreadByThread extends Thread {@Overridepublic void run() {System.out.println("CreateThreadByRunnable#run, 自定义的业务逻辑");}public static void main(String[] args) {Thread thread = new CreateThreadByThread();thread.start();}
}// 输出
CreateThreadByRunnable#run, 自定义的业务逻辑

3.1.2 实现 Runnable 接口

创建一个类,实现 Runnable 接口并覆盖 run 方法是,然后再去创建一个 Thread 类,并将实现 Runnable 接口的对象作为入口传入 Thread 类的构造器,在启动 Thread 对象后程序会去执行 Runnable 对象的 run 方法。

public class CreateThreadByRunnable implements Runnable {@Overridepublic void run() {System.out.println("CreateThreadByRunnable#run, 自定义的业务逻辑");}public static void main(String[] args) {Runnable runnable = new CreateThreadByRunnable();// 将 runnable 对象作为入参传入 Thread 类构造器Thread thread = new Thread(runnable);thread.start();}
}

虽然以上两种形式一个是继承 Thread 类,一个是实现 Runnable 接口,但如果细看源码的话并没有本质上的区别。

首先看继承 Thread 类似的形式,它需要覆盖 run 方法,我们来看看 Thread#run 默认内容是什么:

// Thread#run
@Override
public void run() {if (target != null) {target.run();}
}// Thread#target
/* What will be run. */
private Runnable target;

事实上,Thread#run 方法是覆盖了 Runnable 接口的 run 方法,它的逻辑就是当 Runnable 类型私有成员变量不为空时,执行其 run 方法。

而实现 Runnable 接口的形式,我们在创建完 Runnable 类型的对象后,需要将它作为入参传入 Thread 类的构造器。

// Thread#Thread(java.lang.Runnable)
public Thread(Runnable target) {init(null, target, "Thread-" + nextThreadNum(), 0);
}private void init(ThreadGroup g, Runnable target, String name,long stackSize, AccessControlContext acc,boolean inheritThreadLocals) {// 省略其他代码this.target = target;// 省略其他代码
}

可以看到在 Thread 类似的重载构造方法中,传入的 Runnable 类型的对象赋值给了 Thread 类的 target 私有成员变量。再联系我们刚刚提到的 Thread#run 方法:当 Runnable 类型私有成员变量不为空时,执行其 run 方法。

这不就是换了个皮吗?

所以实现 Runnable 接口的形式跟继承 Thread 类的形式并没有本质上的区别,它们都是基于覆盖 run 方法来实现改变线程需要执行的任务。

3.1.3 实现 Callable 接口

Callable 接口是 JDK1.5 才引入的类,它的功能比 Runnable 更强大,最大的特点是 Callable 允许有返回值,其次它支持泛型,同时它允许抛出异常被外层代码捕获,下面是实现 Callable 接口来创建线程的示例:

public class CreateThreadByCallable implements Callable<Integer> {public static void main(String[] args) {CreateThreadByCallable callable = new CreateThreadByCallable();FutureTask<Integer> future = new FutureTask<>(callable);// 创建线程并启动Thread thread = new Thread(future);thread.start();Integer integer = null;try {integer = future.get();} catch (InterruptedException | ExecutionException e) {e.printStackTrace();}System.out.println("FutureTask 返回内容: " + integer);}@Overridepublic Integer call() throws Exception {System.out.println("CreateThreadByCallable#call, 自定义的业务逻辑,返回1");return 1;}
}

值得注意的是,我们需要基于 FutureTask 类配合使用 Callale 接口,返回值、异常以及泛型都是 FutureTask 类提供的特性。

当深入 FutureTask 的构造器以及其内部方法时,笔者发现了一些新东西。

// FutureTask#FutureTask(java.util.concurrent.Callable<V>)
public FutureTask(Callable<V> callable) {if (callable == null)throw new NullPointerException();this.callable = callable;this.state = NEW;
}

首先在 FutureTask 的构造器中,将 Callable 对象赋值给了 FutureTask 类的 Callable 类型私有成员变量。然后继续构造 Thread 对象,笔者发现咱们使用的 Thread 重载构造方法竟然与实现 Runnable 接口的场景是一致的,也就是说 FutureTask 实现了 Runnable 接口,打开源码一看,果然如此。

// FutureTask 类实现了 RunnableFuture 接口
public class FutureTask<V> implements RunnableFuture<V> {}// RunnableFuture 接口继承于 Runnable 接口
public interface RunnableFuture<V> extends Runnable, Future<V> {}

所以我们构造的 FutureTask 对象是作为 Runnable 类型传入 Thread 类中的,在线程启动时会去执行 FutureTask 内部的 run 方法,我们再来看看 FutureTask#run 方法。

// java.util.concurrent.FutureTask#run
public void run() {if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))return;try {Callable<V> c = callable;if (c != null && state == NEW) {V result;boolean ran;try {// 执行 callable 属性的 call 方法获取返回值result = c.call();ran = true;} catch (Throwable ex) {result = null;ran = false;setException(ex);}if (ran)// 若执行完毕,将返回值赋值给 outcome 属性(在 FutureTask#get 方法中返回)set(result);}} finally {runner = null;int s = state;if (s >= INTERRUPTING)handlePossibleCancellationInterrupt(s);}
}// java.util.concurrent.FutureTask#set
protected void set(V v) {if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {outcome = v;UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final statefinishCompletion();}
}

可以看到,在 FutureTask#run 方法中实际上执行的是 Callable#call 方法。所以说,实现 Callable 接口的形式,最终 Thread 执行的内容还是 Thread#run 方法,只不过这个 run 方法是被 FutureTask 类的 run 方法覆盖了,而调用 Callable#call 方法是 FutureTask#run 的内部预定义逻辑。

3.1.4 创建线程的方法

通过以上三种创建线程任务的形式以及对它们源码的探究,我们可以知道,无论是哪种形式最终还是以覆盖 Thread#run 的形式来实现的。

这里有一个题外话:创建线程的方法有几种?

这个问题在网络上经常被误解读,大部分观点都认为可以通过继承 Thread 类、实现 Runnable 接口以及实现 Callable 接口来创建线程。事实上我在上文中描述的是”创建线程任务的形式“,想要突出的并非创建线程的方法而是创建线程执行任务的方法。

在上文的示例代码中我们可以知道,即便是通过后两种形式(即实现 Runnable 接口、实现 Callable 接口),我们最终还是需要 new Thread 来创建一个线程,只不过是通过传入 Runnable 对象来改变了线程的行为。

所以说,创建线程的方法只有一种:new Thread()

3.2 start

Thread#start 可以说是 Thread 类最常用的方法了,这个方法的作用是让线程开始执行。

调用刚创建好的线程的 start 方法后,该线程将会从 NEW 状态转化成 RUNNABLE 状态,CPU 会在合适的时间分配给该线程时间片,真正执行线程的业务方法。

需要注意的是,一个线程只能调用一次 start 方法,否则就会抛出 IllegalThreadStateException 异常,原因是在 Thread#start 方法中,首先会判断线程状态。

// java.lang.Thread#start
public synchronized void start() {// 若线程状态不为 NEW,抛出异常// A zero status value corresponds to state "NEW".(0值对应的状态是NEW)if (threadStatus != 0)throw new IllegalThreadStateException();// 省略其他逻辑...
}

可以看到,当线程的状态不是 NEW 状态时,再次调用它的 start 方法,将会抛出 IllegalThreadStateException 异常。也就是说,线程一旦完成执行,就不能重新启动了。

3.3 join

Thread#join 方法的作用是等待线程执行完毕,在 JDK1.8 中该方法有三个重载方法,另外两个带参数的重载方法是设置了超时时间:

Thread#join 方法的意义可以这样描述:在A线程内调用B线程的 join 方法,A线程会等待B线程执行完毕再执行。在这个过程中,线程A的状态将由 RUNNABLE 转变为 WAITING,B线程执行完毕后,A线程状态将转变为 RUNNABLE,该结论可以通过下面的示例来验证:

public static void main(String[] args) throws InterruptedException {Thread thread1 = new Thread(() -> {System.out.println("thread1 is running");try {// 为观察效果明显,将睡眠时间设置的长一点Thread.sleep(50000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("thread1 is over");});thread1.start();thread1.join();
}

运行该程序,首先将输出 thread1 is running,然后 thread1 线程进入 sleep 方法,sleep 结束后输出 thread1 is over。而在 thread1 线程 sleep 的过程中,打开 jconsole 工具,可以观察到调用了 thread1.join 方法的 main 线程状态是 WAITING。

上面说的是 Thread#join 方法,如果是设置了超时时间的重载方法,调用某个线程对象 join 方法的线程状态将转变为 TIMED_WAITING。

除此之外,还有一个问题值得思考:在当前线程调用其他线程的 join 方法后,若其他线程尝试获取当前线程已持有的锁,是否会成功?我们来做个实验。

private static String str = "123";private static void testJoinLock() throws InterruptedException {// main 线程先占用 str 资源synchronized (str) {Thread thread1 = new Thread(() -> {System.out.println("thread1 is running");// 子线程尝试占用 str 资源synchronized (str) {System.out.println("thread1 is get str lock");}System.out.println("thread1 is over");});thread1.start();thread1.join();}
}

先声明一个共享资源 str 变量,main 线程首先对该变量加上同步锁,然后实例化一个子线程,子线程中也尝试去给 str 加上同步锁。运行该程序,观察到最终输出的内容是:thread1 is running,且程序一直未终止。

猜测可能子线程时被阻塞了,打开 jconsole,如下图:

果然,观察到 Thread-0 线程的状态是 BLOCKED,且资源拥有者是 main,即该线程被 main 线程阻塞了。

所以,当线程A因调用线程B线程的 join 方法而进入 WAITING 状态时,并不会释放本身已持有的锁资源。

3.4 yield

Thread#yield 方法的作用是释放时间片,让 CPU 再次选择线程执行。这句话潜在的意思是说 CPU 可能选中之前放弃时间片的线程来执行。

值得注意的是,Thread#yield 方法不会释放已经持有的锁资源。

3.5 interrupt

Thread#interrupt 方法的作用是中断线程,调用线程的该方法会请求终止当前线程,需要注意的是该方法仅仅是给当前线程发送了一个终止的信息,并设置中断标志位,最终是否终止是线程自己处理的。

还有两个比较类似的方法:Thread#interrupted, Thread#isInterrupted

Thread#interrupt 方法的作用是检查该线程是否被中断,同时清除中断标志位

Thread#isInterrupted 方法的作用是检查该线程是否被中断,但不清除中断标志位

值得注意的是,当调用线程的 Thread#interrupt 方法时,若当前线程处于 TIMED_WAITING 或 WAITING 状态时(如调用过 Object#wait, Thread#join, Thread#sleep 或对应重载方法的线程),将会抛出 InterruptedException 异常,使得线程直接进入 TERMINATED 状态。

public static void main(String[] args) {Thread thread1 = new Thread(() -> {System.out.println("thread1 is running");try {// 为观察效果明显,将睡眠时间设置的长一点Thread.sleep(50000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("thread1 is over");});thread1.start();// 中断线程thread1.interrupt();
}

执行该方法,输出内容为:

thread1 is running
thread1 is over
java.lang.InterruptedException: sleep interruptedat java.lang.Thread.sleep(Native Method)at io.walkers.planes.pandora.jdk.thread.usage.InterruptMethod.lambda$main$0(InterruptMethod.java:16)at java.lang.Thread.run(Thread.java:748)

可以看到,程序抛出了 InterruptedException 异常。

4. 线程状态

在 Java 语言中,线程被抽象成 Thread 类,而在 Thread 类中有一个 State 枚举类,它描述了线程的各种状态。

// java.lang.Thread.State
public enum State {NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED;
}

JDK 源码的注释对于线程状态的描述如下:

  • NEW:未启动的线程处于这种状态
  • RUNNABLE:在 JVM 中执行的线程处于这种状态
  • BLOCKED:等待锁而被阻塞的线程处于这种状态
  • WAITING:一个线程正在无限期地等待另一个线程执行某个特定的操作,它就处于这种状态
  • TIMED_WAITING:在指定的等待时间内等待另一个线程执行某个操作的线程处于这种状态
  • TERMINATED:已退出的线程处于这种状态

下面我就用代码模拟处于各个状态的线程,同时会例举线程进入该状态的方法。

4.1 NEW

未启动的线程处于 NEW 状态,这个状态十分容易模拟,当我 new 出一个 Thread 对象后,该 Thread 线程就处于 NEW 状态,模拟代码如下:

public static void main(String[] args) {Thread thread = new Thread();System.out.println("Thread state is: " + thread.getState());
}// 程序输出内容
Thread state is: NEW

所以创建一个线程对象后,该线程的状态就是 NEW 状态。

4.2 RUNNABLE

在 JVM 中执行的线程处于 RUNNABLE 状态,即当调用一个线程的 start 方法后,等待 CPU 分配给该线程时间片,该线程正式执行时,他就处于 RUNNABLE 状态,模拟代码如下:

public static void main(String[] args) {Thread thread = new Thread(() -> System.out.println("Thread state is: " + Thread.currentThread().getState()));thread.start();
}// 程序输出内容
Thread state is: RUNNABLE

所以调用一个线程的 start 方法后,若未抛出异常,该线程就会进入 RUNNABLE 状态。

这里需要注意的是,若对于非 NEW 状态的线程调用它的 start 方法,将会抛出 IllegalThreadStateException 异常,原因源码如下:

// java.lang.Thread#start
public synchronized void start() {// 若线程状态不为 NEW,抛出异常// A zero status value corresponds to state "NEW".(0值对应的状态是NEW)if (threadStatus != 0)throw new IllegalThreadStateException();// 省略其他逻辑...
}

除此之外,还有几种情况也会进入 RUNNABLE 状态:

  • BLOCKED 状态下的线程因获取锁成功而进入 RUNNABLE 状态
  • 因调用 sleep, join 方法而进入 WAITING/TIMED_WAITING 状态的线程,超过超时时间、正常等待结束或调用 Object#notify, Object#notifyAll 方法,会进入 RUNNABLE 状态
  • RUNNABLE 状态的线程因调用 yield 方法而重新进入 RUNNABLE 状态

4.3 BLOCKED

线程由于等待锁而被阻塞将处于 BLOCKED 状态,想要模拟该状态下的线程就需要引入共享资源以及第二个线程了,线程1先启动并占有锁资源,然后再启动线程2,当线程2尝试获取锁资源时,发现共享资源已被线程1占用,于是进入阻塞状态。模拟代码如下:

public class StateBlocked {// 共享资源private static String str = "lock";public static void main(String[] args) throws InterruptedException {Thread thread1 = new Thread(() -> {synchronized (str) {System.out.println("Thread1 get lock");// 防止线程 thread1 释放 str 锁资源try {Thread.sleep(10000);} catch (InterruptedException e) {e.printStackTrace();}}});thread1.start();// 保证 thread1 先拿到锁资源Thread.sleep(1000);Thread thread2 = new Thread(() -> {synchronized (str) {System.out.println("Thread1 get lock");}});thread2.start();// 保证 thread2 进入 synchronized 代码块Thread.sleep(1000);System.out.println("Thread2 state is: " + thread2.getState());}
}

上述模拟代码的输出结果如下:

Thread1 get lock
Thread2 state is: BLOCKED
Thread1 get lock

所以线程由于进入同步块尝试获取锁失败被阻塞时,其状态就是 BLOCKED 状态。

4.4 WAITING

一个线程正在无限期地等待另一个线程执行某个特定的操作,它就处于 WAITING 状态。启动一个线程A,在另一个线程B中调用 Thread#join 方法,线程B会等待线程A执行完毕,这时线程B就是 WAITING 状态,模拟代码如下:

public static void main(String[] args) throws InterruptedException {// 锁资源String str = "lock";Thread thread = new Thread(() -> {// sleep 100s 是为了有足够的时间查看线程状态try {Thread.sleep(100000);} catch (InterruptedException e) {e.printStackTrace();}});thread.start();// 主线程等待 thread 线程执行完毕thread.join();
}

执行该方法后,我们需要打开 jconsole,找到对应进程,查看其线程状态,如下图:

可以看到,此时 main 线程的状态是 WAITING。

所以,调用一个线程的 join 方法(不指定超时时间),调用方将进入 WAITING 状态

除此之外,调用一个线程的 Object#wait (不指定超时时间),调用方也将进入 WAITING 状态。

4.5 TIMED_WAITING

在指定的等待时间内等待另一个线程执行某个操作的线程处于 TIMED_WAITING 状态。TIMED_WAITING 状态与 WAITING 状态唯一的不同就是前者指定了超时时间,在上一步代码基础上略作改动,我们就可以模拟出 TIMED_WAITING 状态,模拟代码如下:

public static void main(String[] args) throws InterruptedException {// 锁资源String str = "lock";Thread thread = new Thread(() -> {// sleep 100s 是为了有足够的时间查看线程状态try {Thread.sleep(100000);} catch (InterruptedException e) {e.printStackTrace();}});thread.start();// 主线程等待 thread 线程执行完毕,指定超时时间为 10sthread.join(10000);
}

执行该方法后,打开 jconsole,找到对应进程,查看其线程状态,如下图:

可以看到,此时 main 线程的状态是 TIMED_WAITING。

所以,调用一个线程的 join 方法(指定超时时间),调用方将进入 TIMED_WAITING 状态

除此之外,调用一个线程的 Object#wait (指定超时时间),调用方也将进入 WAITING 状态。

4.5 TERMINATED

已退出的线程处于 TERMINATED 状态,这个状态就是线程自然结束的状态,十分容易模拟,代码如下:

public static void main(String[] args) throws InterruptedException {Thread thread = new Thread();thread.start();// 等待线程 thread 执行完毕thread.join();System.out.println("Thread state is: " + thread.getState());
}

所以,当一个线程正常结束时,它将进入 TERMINATED 状态

除此之外,当一个线程抛异常退出时,也会进入 TERMINATED状态,例如在 WAITING/TIMED_WAITING 状态下的线程调用 Thread#interrupt 方法而退出。

5. 线程状态转换图

将上述的线程状态转换关系总结为如下图:

6. 小结

本文首先介绍了进程与线程的联系与区别,随后描述了 Thread 类常用的 API 以及创建线程执行任务的形式,然后详细说明了线程的状态并作出示例,最后总结了线程状态转换图。

总结了几个小问题:

  • 进程与线程的联系与区别
  • 创建线程任务的形式有哪几种?
  • 创建线程有几种方式?
  • 线程状态有哪几种?
  • 描述一下线程状态的转换规则

腾讯架构师理解的并发编程基石——Thread类的工作原理相关推荐

  1. Java并发编程:Thread类的使用

    为什么80%的码农都做不了架构师?>>>    Java并发编程:Thread类的使用 在前面2篇文章分别讲到了线程和进程的由来.以及如何在Java中怎么创建线程和进程.今天我们来学 ...

  2. 02.并发编程(2)Thread类源码分析

    概述 在说线程之前先说下进程,进程和线程都是一个时间段的描述,是CPU工作时间段的描述. 进程,是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竟争计算机系统资源的基本单位.每一 ...

  3. 【并发编程】Thread类的详细介绍

    本博客系列是学习并发编程过程中的记录总结.由于文章比较多,写的时间也比较散,所以我整理了个目录贴(传送门),方便查阅. Thread类是Java中实现多线程编程的基础类.本篇博客就来介绍下Thread ...

  4. 《深入理解高并发编程:JDK核心技术》-冰河新书上市

    大家好,我是冰河~~ 废话说多了没用,并发编程技术一直是初级程序员进阶高级工程师的前提条件,也是成为大厂程序员的必备技能,更是突破自身技术瓶颈的必经之路. 2022年6月我出版了"冰河技术丛 ...

  5. AQS理解之五—并发编程中AQS的理解

    AQS理解之五-并发编程中AQS的理解 首先看下uml类图: AbstractOwnableSynchronizer 这个类定义是提供一个创建锁的基础,设置一个排它线程,帮助控制和监控访问. 先看下A ...

  6. 《深入理解高并发编程》学习笔记

    文章目录 幕后黑手 可见性 原子性 有序性 解决办法 java内存模型 volatile Happens-Before原则 程序次序规则 volatile变量规则 传递规则 锁定规则 线程启动规则 线 ...

  7. 冰河最新出版的《深入理解高并发编程:核心原理与案例实战》到底讲了些啥?(视频为证)

    大家好,我是冰河~~ 最近有很多小伙伴问我:<深入理解高并发编程:核心原理与案例实战>这本书有没有目录.我:安排!这不,我连夜录制了这本书的整体内容,希望能够为小伙伴们带来实质性的帮助,直 ...

  8. Java 并发编程——Executor框架和线程池原理

    Java 并发编程系列文章 Java 并发基础--线程安全性 Java 并发编程--Callable+Future+FutureTask java 并发编程--Thread 源码重新学习 java并发 ...

  9. Java Review - 并发编程_原子操作类LongAdder LongAccumulator剖析

    文章目录 概述 小Demo 源码分析 重要的方法 long sum() reset sumThenReset longValue() add(long x) longAccumulate(long x ...

最新文章

  1. mysql数据类型分析_MYSQL数据类型分析整理
  2. Linux_DNS服务器
  3. Chrome Extension in CLJS —— 搭建开发环境
  4. Node.js与Express4安装与配置
  5. 015_获取并设置CSS类
  6. java同步异步调用_详解java 三种调用机制(同步、回调、异步)
  7. 实战SSM_O2O商铺_33【商品】商品编辑之Service层的实现
  8. 总分 Score Inflation
  9. mysql中文显示问号
  10. 文献记录(part20)--Discriminative metric learning for multi-view graph partitioning
  11. linux小程序实验报告,linux 小程序分析
  12. JAVA JDK 、Maven、IDEA安装
  13. 公共命名空间和office文档
  14. php输入为空,ecshop搜索框内容为空提示用户输入内容
  15. 归一法的计算方法讲解_归一法
  16. CentOS7下安装和配置MySQL5.7亲测有效(附图文)
  17. [2014]兄弟连高洛峰 PHP教程1.1.1 新版视频形式介绍
  18. strchr和strstr的用法
  19. intouch中DA server的配置文件
  20. 中英文论文写作指导第二部分(前言)

热门文章

  1. SQL Subquery
  2. iOS开发中如何添加应用自己的字体
  3. 前端开发入门到实战:HTML5 video视频播放
  4. 你家酱油“氨基酸态氮”是多少?酱油的选择
  5. 计算机网络(6) ——同步IO/异步IO专题
  6. java schtasks 不生效,at和schtasks的比较
  7. 别再喊我调参侠!夕小瑶“科学炼丹”手册了解一下
  8. Elasticsearch:倒数排序融合 - Reciprocal rank fusion (RRF)
  9. 多人文件共享就看派盘
  10. windows自动关闭nginx脚本