Java多线程基础知识

进程和线程区别

  • 根本区别:进程是操作系统资源分配(CPU、内存等)的基本单位,而线程是处理器任务调度和执行的基本单位。

  • 包含关系:通常一个进程都有若干个线程,至少包含一个线程。

  • 内存分配:一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK8之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈

  • 程序计数器主要有下面两个作用:

    • 字节码解释器通过改变程序计数器的值,来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

    • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候,能够知道该线程上次运行到哪儿了

    所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

  • 虚拟机栈和本地方法栈为什么是私有的?

    • 虚拟机栈:每个Java方法在执行的同时,会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。

    • 本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。 在HotSpot虚拟机中合二为一。

    所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

  • 堆和元空间

    • 堆和元空间是所有线程共享的资源;
    • 堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存);
    • 元空间主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

单核与多核CPU的进程与线程

单核CPU的进程与线程

  • 实现多进程依靠于操作系统的进程调度算法,比如时间片轮转算法。例如有3个正在运行的程序(即三个进程),操作系统会让单核CPU轮流来运行这些进程,这样看起来就像多个进程同时在运行,从而实现多进程。

  • 通常一个任务不光CPU上要花时间,IO上也要花时间(例如去数据库查数据,去抓网页,读写文件等)。 一个进程在等IO的时候,CPU是闲置的,另一个进程正好可以利用CPU进行计算。 多几个进程一起跑,可以把IO和CPU都跑满了。

  • 单核CPU同一时间只能处理1个线程,只有1个线程在执行。多线程同时执行,是CPU快速的在多个线程之间切换实现的。CPU调度线程的时间足够快,就造成了多线程的“同时”执行。如果线程数非常多,CPU会在\(n\)个线程之间切换,消耗大量的CPU资源。每个线程被调度的次数会降低,线程的执行效率降低。

  • 一个拥有两个线程的进程的执行时间,可能比一个线程的进程执行两遍的时间还长一点。因为线程的切换也需要时间。即采用多线程可能不会提高程序的运行速度,反而会降低速度,但是对于用户来说,可以减少用户的响应时间。

多核CPU的进程与线程

  • 多核CPU是一枚处理器(同一时间只有1个线程在执行)中集成多个完整的计算引擎(内核)。多核CPU和单核CPU对于进程来说都是并发,并不是并行。

  • 但是多核CPU每一个核心都可以独立执行一个线程,所以多核CPU可以真正实现多线程的并行。例如,四核可以把线程1、2、3、4分配给核心1、2、3、4,如果还有线程5、6、7,就要等待CPU的调度。线程1、2、3、4属于并行;如果一会核心1停止执行线程1,改为执行线程5,那线程1、5属于并发

并行和并发

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行

并发编程三个核心问题

上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。

时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

这就像同时读两本书,当在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。

这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度并发执行的速度可能会比串行慢,因为线程有创建和上下文切换的开销

如何减少上下文切换

  1. 无锁并发编程
    多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁。

  2. CAS算法
    Java的Atomic包使用CAS算法来更新数据,而不需要加锁。

  3. 协程
    在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

  4. 使用最少的线程

并发编程适用的场景

多线程不一定就比单线程高效,比如Redis,因为它是基于内存操作,这种情况下,单线程可以很高效的利用CPU。而多线程的使用场景一般时存在相当比例的I/O或网络操作

CPU密集型程序

定义:一个完整请求,I/O操作可以在很短时间内完成,CPU还有很多运算要处理,也就是说CPU计算的比例占很大一部分
使用场景:计算1+2+….100亿的总和。

单核CPU

单核CPU下,如果创建4个线程来分段计算,即:线程1计算 [1,25亿);...线程4计算 [75亿,100亿]

由于是单核CPU所有线程都在等待CPU时间片。按照理想情况来看,四个线程执行的时间总和与一个线程5独自完成是相等的,但不能忽略了四个线程上下文切换的开销

所以,单核CPU处理CPU密集型程序,这种情况并不太适合使用多线程!

多核CPU

此时如果在4核CPU下,同样创建四个线程来分段计算:

每个线程都有CPU来运行,并不会发生等待CPU时间片的情况,也没有线程切换的开销。理论情况来看效率提升了4倍。

所以,如果是多核CPU处理CPU密集型程序,完全可以最大化的利用CPU核心数,应用并发编程来提高效率!

创建线程的合适数量

线程数量 = CPU核数(逻辑)+ 1

I/O密集型程序

定义:与CPU密集型程序相对,一个完整请求,CPU运算操作完成之后,还有很多I/O操作要做,也就是说I/O操作占比很大部分

在进行I/O操作时,CPU是空闲状态,所以要最大化的利用CPU,不能让其是空闲状态。

单核CPU的情况下:

从上图中可以看出,每个线程都执行了相同长度的CPU耗时和I/O耗时,如果将上面的图多画几个周期,CPU操作耗时固定,将I/O操作耗时变为CPU耗时的3倍,那么CPU又有空闲了(在CPU空闲时,创建新线程执行任务,避免使其空闲!),这时就可以新建线程4,来继续最大化的利用CPU。

创建线程的合适数量

一个CPU核心的最佳线程数:最佳线程数 = (1/CPU利用率) = 1 + (I/O耗时/CPU耗时)

多个CPU核心的最佳线程数:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))

线程安全问题

线程安全问题只在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下四个维度考量:

  • 数据单线程内可见。单线程总是安全的。通过限制数据仅在单线程内可见,可以避免数据被其他结程篡改。

    • 最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线
      程安全的。
  • 只读对象。只读对象总是安全的。它的特性是允许复制、拒绝写人。最典型的只读对象有StringInteger等。一个对象想要拒绝任何写人,必须要满足以下条件:

    • 使用final关键字修饰类,避免被继承,使用private final关键字避免属性被中途修改;没有任何更新方法;返回值不能可变对象为引用。
  • 线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,它采用synchronized关键字来修饰相关方法。

  • 同步与锁机制。如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。

线程安全的核心理念就是要么只读要么加锁。合理利用好JDK提供的并发包,往往能化腐朽为神奇。并发包主要分成以下几个类族:

  • 线程同步类。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Objectwait()notify()进行同步的方式。主要代表为CountDownLatchSemaphoreCyclicBarrier等。

  • 并发集合类。集合并发操作的要求是执行速度快,提取数据准。最著名的类非ConcurrentHashMap莫属,它不断地优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有ConcurrentSkipListMapCopyOnWriteArrayListBlockingQueue等。

  • 线程管理类。虽然ThreadThreadLocal在JDKl.O 就已经引入,但是真正把Thread发扬光大的是线程池。根据实际场景的需要,提供了多种创建线程池的快捷方式,如使用Executors静态工厂或者使用ThreadPoolExecutor等。另外,通过ScheduledExecutorService来执行定时任务。

  • 锁相关。锁以Lock接口为核心,派生出在一些实际场景中进行互斥操作的锁相关类。最有名的是ReentrantLock

什么是锁

并发包中的锁类

并发包的类族中,Lock是JUC包的顶层接口,它的实现逻辑并未用到synchronized,而是利用了volatile可见性

在Lock的继承类图中,ReentrantLock对于Lock接口的实现主要依赖了Sync,而Sync继承了AbstractQueuedSynchronizer(AQS), 它是JUC包实现同步的基础工具。
在AQS中, 定义了一个volatile int state变量作为共享资源

  • 如果线程获取资源失败,则进入同步FIFO队列中等待;
  • 如果成功获取资源就执行临界区代码。
  • 执行完释放资源时,会通知同步队列中的等待线程,来获取资源后出队并执行。

AQS是抽象类,内置自旋锁实现的同步队列,封装入队和出队的操作,提供独占、共享、中断等特性的方法。AQS的子类可以定义不同的资源,实现不同性质的方法:

  • 可重入锁ReentrantLock, 定义state为0时可以获取资源并置为1。若已获得资源,state不断加1,在释放资源时state减1,直至为0;

  • CountDownLatch初始时定义了资源总量state=countcountDown()不断将state减1,当state=0时才能获得锁,释放后state就一直为0。所有线程调用await()都不会等待,所以CountDownLatch是一次性的,用完后如果再想用就只能重新创建一个,如果希望循环使用,推荐使用基于ReentrantLock实现的CyclicBarrier

  • SemaphoreCountDownLatch略有不同,同样也是定义了资源总量state=permits,当state>0时就能获得锁,并将state减1,当state=0时只能等待其他线程释放锁,当释放锁时state加1,其他等待线程又能获得这个锁。

    • 当Semphore的permits定义为1时,就是互斥锁,当permits>1就是共享锁

利用同步代码块

同步代码块一般使用Java的synchronized关键字来实现,有两种方式对方法进行加锁操作

  • 方法签名处加synchronized关键字;
  • 使用synchronized(对象或类)进行同步。

这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类;能锁代码块,就不要锁方法

synchronized锁特性由JVM负责实现。在JDK的不断优化迭代中,synchronized锁的性能得到极大提升,特别是偏向锁的实现,使得synchronized已经不是昔日那个低性能且笨重的锁了。

JVM底层是通过监视锁来实现synchronized同步的。监视锁即monitor,是每个对象与生俱来的一个隐藏字段。使用synchronized时,JVM会根据synchronized的当前使用环境,找到对应对象的monitor,再根据monitor的状态进行加、解锁的判断。

例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的monitor,进行加锁判断。如果成功加锁就成为该monitor的唯一持有者。monitor在被释放前,不能再被其他线程获取

方法元信息中会使用ACC_SYNCHRONIZED标识该方法是一个同步方法同步代码块中会使用monitorentermonitorexit两个字节码指令获取和释放monitor。

JVM对synchronized的优化主要在于对monitor的加锁、解锁上。JDK6后不断优化使得synchronized提供三种锁的实现,包括偏向锁轻量级锁重量级锁,还提供自动的升级和降级机制。JVM就是利用CAS在对象头上设置线程ID,表示这个对象偏向于当前线程,这就是偏向锁。

线程同步

在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。

所谓原子性是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的修改都是原子操作,就不存在线程同步问题。

有些看似非常简单的操作其实不具备原子性,典型的就是i=++操作,它需要分为三步,即ILOAD → IINC → ISTORE。另一方面,更加复杂的CAS(Compare and Swap)操作却具有原子性。

参考资料

进程和线程的区别(超详细)

posted @ 2021-03-09 15:18  chenzufeng  阅读(56)  评论(0编辑  收藏  举报