Java多线程与高并发专题——基础篇1

基础概览

进程与线程

什么是进程?

进程是指运行中的程序。 比如我们使用聊天软件,浏览器,需要启动这个程序,操作系统会给这个程序分配一定的资源。

什么线程?

线程是CPU调度的基本单位,每个线程执行的都是某一个进程的代码的某个片段。

进程是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。

在Java中,当我们启动main函数时其实就启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。

一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。

什么是多线程?

多线程是指:单个进程中同时运行多个线程。

多线程的目的是为了提高CPU的利用率。

可以通过避免一些网络IO或者磁盘IO等需要等待的操作,让CPU去调度其他线程。这样可以大幅度的提升程序的效率,提高用户的体验。

  • 比如Tomcat可以做并行处理,提升处理的效率,而不是一个一个排队。
  • 比如要处理一个网络等待的操作,开启一个线程去处理需要网络等待的任务,让当前业务线程可以继续往下执行逻辑,效率是可以得到大幅度提升的。

多线程的局限

如果线程数量特别多,CPU在切换线程上下文时,会额外造成很大的消耗。

任务的拆分需要依赖业务场景,有一些异构化的任务,很难对任务拆分,还有很多业务并不是多线程处理更好。

线程安全问题:虽然多线程带来了一定的性能提升,但是在做一些操作时,多线程如果操作临界资源,可能会发生一些数据不一致的安全问题,甚至涉及到锁操作时,会造成死锁问题。

Java的多线程

Java的多线程是指在一个Java程序中同时执行多个线程的能力。线程是程序执行的最小单位,多线程编程允许一个程序在同一时间内执行多个任务。

Java通过java.lang.Thread类和java.util.concurrent 包提供了强大的多线程支持。

串行、并行、并发

什么是串行(Serial)

串行就是一个一个排队,第一个做完,第二个才能上。

任务一个接一个地执行。

特点:

  • 任务按顺序执行,一个任务完成后下一个任务才开始。

  • 没有任务之间的重叠。

  • 简单易理解,但效率较低,特别是在处理大量任务时。

什么是并行(Parallel)

并行就是同时处理。(一起上!!!)

任务同时执行,需要多核处理器。

特点:

  • 任务在同一时间点上同时执行。

  • 通常需要多核处理器或多台计算机来实现真正的并行。

  • 可以显著提高性能,特别是在处理计算密集型任务时。

什么是并发(Concurrent)

这里是多线程中的并发概念(CPU调度线程的概念)

CPU在极短的时间内,反复切换执行不同的线程,看似好像是并行,但是只是CPU高速的切换。

任务交替执行,看起来像是同时进行的。

特点:

  • 任务在时间上交替进行,可能并不真正同时运行,但在宏观上看起来是同时进行的。

  • 不需要多核处理器,但可以利用多核处理器提高性能。

  • 适用于I/O密集型任务和需要高响应性的应用。

关于并行与并发

并行:任务在物理上同时执行,需要多核处理器。

并发:任务在逻辑上同时进行,但在物理上是交替执行的。

并行和并发在某些情况下会重叠,但它们并不是完全相同的概念。

关于线程的创建

为什么说本质上只有一种实现线程的方式?

实现线程的方式到底有几种?大部分人会说有 2 种、3 种或是 4 种,很少有人会说有 1 种。

我们接下来看看这些所谓的实现线程的方式具体是什么:

实现 Runnable 接口

第 1 种方式是通过实现 Runnable 接口实现多线程,如下面代码所示:

public class RunnableThread implements Runnable {
    @Override
    public void run() {
        System.out.println('实现Runnable接口');
    }
}

首先通过 RunnableThread 类实现 Runnable 接口,然后重写 run() 方法,之后只需要把这个实现了 run() 方法的实例传到 Thread 类中就可以实现多线程。

继承 Thread 类

第 2 种方式是继承 Thread 类,如下代码所示:

public class ExtendsThread extends Thread {
    @Override
    public void run() {
        System.out.println('用Thread类实现线程');
    }
}

与第 1 种方式不同的是它没有实现接口,而是继承 Thread 类,并重写了其中的 run() 方法。

线程池创建线程

第 3 种方式是通过线程池创建线程,比如我们给线程池的线程数量设置成 10,那么就会有 10 个子线程来为我们工作。如下代码所示:

static class DefaultThreadFactory implements ThreadFactory {
    private final ThreadGroup group;
    private final String namePrefix;
    private final AtomicInteger threadNumber = new AtomicInteger(1);

    DefaultThreadFactory() {
        SecurityManager s = System.getSecurityManager();
        AtomicInteger poolNumber = new AtomicInteger(1);
        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;
    }
}

对于线程池而言,本质上是通过线程工厂创建线程的,默认采用 DefaultThreadFactory ,它会给线程池创建的线程设置一些默认值,比如:线程的名字、是否是守护线程,以及线程的优先级等。但是无论怎么设置这些属性,最终它还是通过 new Thread() 创建线程的 ,只不过这里的构造函数传入的参数要多一些,由此可以看出通过线程池创建线程,并没有脱离前面的那两种基本的创建方式,因为本质上还是通过 new Thread() 实现的。

有返回值的 Callable 创建线程

第 4 种线程创建方式是通过有返回值的 Callable 创建线程,Runnable 创建线程是无返回值的,而 Callable 和与之相关的 Future、FutureTask,它们可以把线程执行的结果作为返回值返回,如下代码所示:

class CallableTask implements Callable {
    @Override
    public Integer call() throws Exception {
        return new Random().nextInt();
    }
}
//创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
//提交任务,并用 Future提交返回结果
Future future = service.submit(new CallableTask());

实现了 Callable 接口,并且给它的泛型设置成 Integer,然后它会返回一个随机数。但是,无论是 Callable 还是 FutureTask,它们首先和 Runnable 一样,都是一个任务,是需要被执行的,而不是说它们本身就是线程。

它们可以放到线程池中执行,如上面代码所示, submit() 方法把任务放到线程池中,并由线程池创建线程,不管用什么方法,最终都是靠线程来执行的,而子线程的创建方式仍脱离不了最开始讲的两种基本方式,也就是实现 Runnable 接口和继承 Thread 类。

定时器 Timer

第5种则是通过定时器实现,比如果新建 一个 Timer,令其每隔 10 秒或设置1个小时之后,执行一些任务,那么这时它确实也创建了线程并执行了任务,但如果我们深入分析定时器的源码会发现,本质上它还是会有一个继承自 Thread 类的 TimerThread,所以定时器创建线程最后又绕回到最开始说的两种方式。

class TimerThread extends Thread {
    //具体实现
}

其他方法

其实还有其它的一些其他方式,比如匿名内部类或 lambda 表达式方式,实际上,匿名内部类或 lambda 表达式创建线程,它们仅仅是在语法层面上实现了线程,本质上来说,并不能把它归结于实现多线程的新方式,如匿名内部类实现线程的代码所示,它仅仅是用一个匿名内部类把需要传入的 Runnable 给实例出来。

new Thread(() -> System.out.println(Thread.currentThread().getName())).start();}

总结

关于这个问题,我们先不聚焦为什么说创建线程只有一种方式,先认为有两种创建线程的方式,而其他 的创建方式,比如线程池或是定时器,它们仅仅是在 new Thread() 外做了一层封装,如果我们把这些都叫作一种新的方式,那么创建线程的方式便会千变万化、层出不穷,比如 JDK 更新了,它可能会多出几个类,会把 new Thread() 重新封装,表面上看又会是一种新的实现线程的方式,透过现象看本质, 打开封装后,会发现它们最终都是基于 Runnable 接口或继承 Thread 类实现的。

接下来,我们进行更深层次的探讨,为什么说这两种方式本质上是一种呢?

@Override
public void run() {
    if (target != null) {
        target.run();
    }
}

首先,启动线程需要调用 start() 方法,而 start() 方法最终还会调用 run() 方法。

  • 我们先来看看第一种方式中 run() 方法究竟是怎么实现的,可以看出 run() 方法的代码非常短小精悍,第 1 行代码 if (target != null) ,判断 target 是否等于 null,如果不等于 null,就执行第 2 行代码 target.run(),而 target 实际上就是一个 Runnable,即使用 Runnable 接口实现线程时传给Thread类的对象。
  • 然后,我们来看第二种方式,也就是继承 Thread 方式,实际上,继承 Thread 类之后,会把上述的 run() 方法重写,重写后 run() 方法里直接就是所需要执行的任务,但它最终还是需要调用 thread.start() 方法来启动线程,而 start() 方法最终也会调用这个已经被重写的 run() 方法来执行它的任务。

这时我们就可以彻底明白了,事实上创建线程只有一种方式,就是构造一个 Thread 类,这是创建线程的唯一方式。 我们上面已经了解了两种创建线程方式本质上是一样的,它们的不同点仅仅在于实现线程运行内容的不同。

那么运行内容来自于哪里呢?

运行内容主要来自于两个地方,要么来自于 target,要么来自于重写的 run() 方法,在此基础上我们进行拓展。

总结起来,本质上,实现线程只有一种方式,而要想实现线程执行的内容,却有两种方式,也就是可以通过实现 Runnable 接口的方式,或是继承 Thread 类重写 run() 方法的方式,把我们想要执行的代码传入,让线程去执行,在此基础上,如果我们还想有更多实现线程的方式,比如线程池和 Timer 定时器,只需要在此基础上进行封装就可以了。

实现 Runnable 接口究竟比继承 Thread 类实现线程好在哪里?

首先,我们从代码的架构考虑,实际上,Runnable 里只有一个 run() 方法,它定义了需要执行的内容, 在这种情况下,实现了 Runnable 与 Thread 类的解耦,Thread 类负责线程启动和属性设置等内容,权责分明。

第二点就是在某些情况下可以提高性能,使用继承 Thread 类方式,每次执行一次任务,都需要新建一个独立的线程,执行完任务后线程走到生命周期的尽头被销毁,如果还想执行这个任务,就必须再新建一个继承了 Thread 类的类,如果此时执行的内容比较少,比如只是在 run() 方法里简单打印一行文字, 那么它所带来的开销并不大,相比于整个线程从开始创建到执行完毕被销毁,这一系列的操作比 run() 方法打印文字本身带来的开销要大得多,相当于捡了芝麻丢了西瓜,得不偿失。如果我们使用实现 Runnable 接口的方式,就可以把任务直接传入线程池,使用一些固定的线程来完成任务,不需要每次新建销毁线程,大大降低了性能开销。

第三点好处在于 Java 语言不支持多继承,如果我们的类一旦继承了 Thread 类,那么它后续就没有办法再继承其他的类,这样一来,如果未来这个类需要继承其他类实现一些功能上的拓展,它就没有办法做到了,相当于限制了代码未来的可拓展性。

综上所述,我们应该优先选择通过实现 Runnable 接口的方式来创建线程。

 

你可能感兴趣的:(大数据面试,java,开发语言,并发编程)