多线程与线程并发

目前没有时间排版,已排版链接:https://blog.csdn.net/qq_36010886/article/details/126640562


什么是线程和进程?

      进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

      线程是一个比进程更小的执行单位,一个进程里可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源。每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

程序计数器为什么是私有的?

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

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

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

        注意:

        如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

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

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

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

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

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

堆和方法区

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

为什么要使用多线程呢?

为了合理利用 CPU 的高性能。

使用多线程可能带来什么问题

      并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但可能会遇到很多问题,比如:数据不准确、内存泄漏、死锁、线程不安全等。

        线程安全的大致推导过程就是:

        多线程安全问题-->线程通讯-->类变量/实例变量-->对变量的安全访问问题-->同步手段(加锁synchronized/ReentrantLock)

并发问题的根源(并发三要素)

1)可见性(CPU缓存)

2)原子性(分时复用)

3)有序性(重排序)编译器重排序;执行级并行重排序;内存系统重排序。

如何解决并发问题?

          Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。 从两种维度思考:

          维度一:三个关键字+Happens-Before规则

          维度二:可见性,有序性,原子性

Happens-Before规则

1)单一线程原则。在一个线程内,在程序前面的操作先行发生于后面的操作。

2)管程锁定规则。一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

3)volatile 变量规则。对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

4)线程启动规则。Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

5)线程加入规则Thread 对象的结束先行发生于 join() 方法返回。

6)线程中断规则。对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

7)对象终结规则。一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8)传递性。如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

线程安全的实现办法

互斥同步

      互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。常见的是synchronized 和 ReentrantLock。

非阻塞同步

        非阻塞同步是使用基于冲突检测的乐观并发策略。 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。乐观锁需要操作和冲突检测这两个步骤具备原子性,需要靠硬件来完成

        1)CAS指令 (CPUU)

        2)AtomicInteger(JUC)

        3)ABA 需要添加版本标识。

无同步方案

        1)栈封闭 (线程私有)

        2)线程本地存储(注意内存泄露)

        3)可重入代码(纯代码)

线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

NEW: 初始状态,线程被创建出来但没有被调用 start() 。

RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。

BLOCKED :阻塞状态,需要等待锁释放。

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

TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

为什么JVM没有区分可运行和运行中状态?

        现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。

什么是上下文切换

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

主动让出 CPU,比如调用了 sleep(), wait() 等。

时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。

调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。

被终止或结束运行

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

      上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这会占用 CPU,内存等系统资源进行处理,频繁切换就会造成整体效率低下。

什么是线程死锁?如何避免死锁?

        线程死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

        要防止死锁就破坏死锁产生的必要条件。避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

产生死锁的四个必要条件:

互斥条件:该资源任意一个时刻只由一个线程占用。

请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。

不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。

循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

sleep() 方法和 wait() 方法对比

共同点 :两者都可以暂停线程的执行。

区别

sleep() 方法没有释放锁,而 wait() 方法释放了锁

wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。

wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。

sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。

为什么 wait() 方法不定义在 Thread 中

          wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

          类似的问题:为什么 sleep() 方法定义在 Thread 中?因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

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

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

并发与并行的区别

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

并行:两个及两个以上的作业在同一 时刻 执行。

同步和异步的区别

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

异步 :调用在发出之后,不用等待返回结果,该调用直接返回。

JMM内存模型

volatile关键字

        在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取,它最原始的意义是禁用CPU缓存。volatile 关键字能保证数据的可见性,但不能保证数据的原子性。volatile 关键字可以防止 JVM 的指令重排序

synchronized 关键字

      synchronized 主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

      synchronized 属于 重量级锁。 因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

        JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。

如何使用 synchronized 关键字

修饰实例方法(锁当前对象实例)

修饰静态方法(锁当前类)

修饰代码块(锁指定对象/类)

注意: 

1、另外synchronized 关键字加到 static 静态方法都是给 Class 类上锁;

2、尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能

3、构造方法不能使用 synchronized 关键字修饰,构造方法本身就属于线程安全的,不存在同步的构造方法一说

synchronized 关键字的底层原理

        synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

        synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

        不过两者的本质都是对对象监视器 monitor 的获取。

Java对象头

        在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充对象头中包含两部分:MarkWord 和 类型指针如果是数组对象的话,对象头还有一部分是存储数组的长度。多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作

1、MarkWord

        Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位,64位JVM -> MarkWord是64位)。

2、类型指针

虚拟机通过这个指针确定该对象是哪个类的实例。

3、对象头的长度

长度内容说明

32/64bitMarkWord存储对象的hashCode或锁信息等

32/64bitClass Metadada Address存储对象类型数据的指针

32/64bitArray Length数组的长度(如果当前对象是数组)

        如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。

优化后synchronized锁的分类

级别从低到高依次是:

无锁状态

偏向锁状态

轻量级锁状态

重量级锁状态

每个锁状态对应对象头中的 MarkWord的内容如下(以32位系统为例):

1、无锁状态

25bit4bit1bit(是否是偏向锁)2bit(锁标志位)

对象的hashCode对象分代年龄001

        这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。

2、偏向锁状态

23bit2bit4bit1bit2bit

线程IDepoch对象分代年龄101

      这里 线程ID 和 epoch 占用了 hashCode 的位置。如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。

epoch:

      对于偏向锁,如果 线程ID = 0 表示未加锁

什么时候会计算 HashCode 呢?

      将对象作为 Map 的 Key 时会自动触发计算。日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。

3、轻量级锁状态

30bit2bit

指向线程栈锁记录的指针00

这里指向栈帧中的 Lock Record 记录,里面当然可以记录对象的 identityHashCode。

4、重量级锁状态

30bit2bit

指向锁监视器的指针10

这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。

锁的升级

1)偏向锁

        偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。偏向锁的锁升级需要进行偏向锁的撤销。

a、偏向锁的加锁

      偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID。如果成功,则获取偏向锁成功。如果失败,则进行锁升级。      偏向锁标志是已偏向状态。MarkWord 中的线程 ID 是自己的线程 ID,成功获取锁;MarkWord 中的线程 ID 不是自己的线程 ID,需要进行锁升级

b、偏向锁的撤销

      对象是不可偏向状态。不需要撤销。

      对象是可偏向状态

      1)MarkWord 中指向的线程不存活。允许重偏向:退回到可偏向但未偏向的状态;不允许重偏向:变为无锁状态。

      2)MarkWord 中的线程存活。线程ID指向的线程仍然拥有锁,升级为轻量级锁,将 mark word 复制到线程栈中;不再拥有锁,允许重偏向:退回到可偏向但未偏向的状态,不允许重偏向:变为无锁状态。

小结:

        撤销偏向的操作需要在全局检查点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁升级为轻量级锁,线程B自旋请求获得锁。

2)轻量级锁

轻量级锁是因为它仅仅使用 CAS 进行操作,实现获取锁。

a、加锁流程

        如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。

b、撤销流程

        轻量级锁解锁时如果对象的Mark Word仍然指向着线程的锁记录,会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。

重量级锁

      重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

synchronized 和 volatile 的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

synchronized 和 ReentrantLock 的区别

相同点:

1)两者都是可重入锁。同一个线程每次获取锁,锁的计数器都自增 1。

不同点:

1)synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API(JDK 层面)。

2)ReentrantLock增加了一些高级功能。比如:等待可中断、可实现公平锁、可实现选择性通知

final

      意为不可变。java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是一弱内存数据模型处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。

      可修饰类、方法、参数、变量修饰之后禁止对final域读、写相关内容进行重排序

      1)final域为基本类型。

      2)final域为引用类型。

final的实现原理

      写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。

      读final域会要求编译器在读final域的操作前插入一个LoadLoad屏障

      另外处理器不同,实现原理不一样。如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略

ThreadLocal(线程变量)

        ThreadLocal类主要解决的就是让每个线程绑定自己的值,ThreadLocal中填充的变量属于当前线程,对其他线程而言是隔离的,每个线程可以访问自己内部的副本变量。

原理

        线程最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

      每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocal 内存泄露问题是怎么导致的

      ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉,会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

线程池

        线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

使用线程池的好处

降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Executor 框架结构(主要由三大部分组成)

1)任务(Runnable /Callable)

        执行任务需要实现的 Runnable 接口Callable接口Runnable 接口Callable 接口 实现类都可以被 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。

2)任务的执行(Executor)

        任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutorScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口

Executor 执行过程

主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。

把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command))或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)或 ExecutorService.submit(Callable task))。

如果执行 ExecutorService.submit(…),ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。

最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。

ScheduledThreadPoolExecutor

        ScheduledThreadPoolExecutor 主要用来在给定的延迟后运行任务,或者定期执行任务。ScheduledThreadPoolExecutor 使用的任务队列 DelayQueue 封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序,执行所需时间短的放在前面先被执行(ScheduledFutureTask 的 time 变量小的先执行),如果执行所需时间相同则先提交的任务将被先执行(ScheduledFutureTask 的 squenceNumber 变量小的先执行)。

ScheduledThreadPoolExecutor 和 Timer 的比较:

Timer 对系统时钟的变化敏感,ScheduledThreadPoolExecutor不是;

Timer 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 ScheduledThreadPoolExecutor 可以配置任意数量的线程。 此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程;

在TimerTask 中抛出的运行时异常会杀死一个线程,从而导致 Timer 死机:-( ...即计划任务将不再运行。ScheduledThreadExecutor 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 afterExecute 方法ThreadPoolExecutor)。抛出异常的任务将被取消,但其他任务将继续运行。

实现 Runnable 接口和 Callable 接口的区别

Runnable 接口 不会返回结果或抛出检查异常,但是 Callable 接口 可以。

执行 execute()方法和 submit()方法的区别是什么

execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

如何创建线程池

方式一:通过构造方法实现。推荐使用

方式二:通过 Executor 框架的工具类 Executors 来实现。三种类型的 ThreadPoolExecutor:

FixedThreadPool该方法返回一个固定线程数量的线程池(无界线程池)。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

SingleThreadExecutor: 方法返回一个只有一个线程的线程池(无界线程池)。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

ThreadPoolExecutor 类分析

ThreadPoolExecutor 3 个最重要的参数:

corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。

maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数:

keepAliveTime:当线程池中的线程数量大于核心线程数的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁

unit : keepAliveTime 参数的时间单位。

threadFactory :executor 创建新线程的时候会用到。

handler :饱和策略。

ThreadPoolExecutor 饱和策略

      如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:

ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。默认

ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉

ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求

线程池原理分析

为什么不推荐使用FixedThreadPool?

        FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响 :

当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;

由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。

由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数;

运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。

为什么不推荐使用SingleThreadExecutor?

          SingleThreadExecutor 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Intger.MAX_VALUE)。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点就是可能会导致 OOM。

为什么不推荐使用CachedThreadPool?

        CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,可能会创建大量线程,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

线程池数量设置方法:

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

        CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

Java 常见并发容器总结

JDK 提供的这些容器大部分在 java.util.concurrent 包中。

ConcurrentHashMap : 线程安全的 HashMap

CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。

ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。

BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。

ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。

ConcurrentHashMap

        在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术(JDK1.7)只对所操作的段加锁而不影响客户端对其它段的访问。

CopyOnWriteArrayList

      CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。写入操作 add()方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。

ConcurrentLinkedQueue/BlockingQueue

        BlockingQueue阻塞队列 、ConcurrentLinkedQueue 非阻塞队列。阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。

ArrayBlockingQueue

      ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。

LinkedBlockingQueue

      LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE 。

PriorityBlockingQueue

        是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候如果空间不够的话会自动扩容)。

        简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

ConcurrentSkipListMap

        跳表。跳表是一种利用空间换时间的算法。跳表的本质是同时维护了多个链表,并且链表是分层的,最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。跳表内的所有链表的元素都是排序的。

        使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap。

Atomic 原子类

        Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

JUC 包中的原子类是哪 4 类?

基本类型

使用原子的方式更新基本类型

AtomicInteger:整型原子类

AtomicLong:长整型原子类

AtomicBoolean:布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

AtomicIntegerArray:整型数组原子类

AtomicLongArray:长整型数组原子类

AtomicReferenceArray:引用类型数组原子类

引用类型

AtomicReference:引用类型原子类

AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

AtomicMarkableReference :原子更新带有标记位的引用类型

对象的属性修改类型

AtomicIntegerFieldUpdater:原子更新整型字段的更新器

AtomicLongFieldUpdater:原子更新长整型字段的更新器

AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器

实现原理

原子性操作的实现是基于CAS。但CAS存在一些缺点:

1)ABA问题;通过增加版本号解决

2)循环时间长开销大,CAS中使用的失败重试机制;设置重试时间。

3)只能保证一个共享变量的原子操作。

      Java中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到操作成功为止

      在CAS中有三个操作数:分别是内存地址(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧的预期值A时,处理器才会用新值B更新V的值,否则他就不执行更新,但无论是否更新了V的值,都会返回V的旧值。

AQS

        AQS 是一个用来构建锁和同步器的框架,日常中常见的是 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基于 AQS 的。

AQS 原理分析

        如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

AQS 定义两种资源共享方式

Exclusive

(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

Share(共享):多个线程可同时执行,如CountDownLatch、Semaphore、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

      ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。

AQS 底层使用了模板方法模式

        同步器的设计是基于模板方法模式的,自定义同步器大致过程:

使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)

将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法

AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:

protectedbooleantryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。

protectedbooleantryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。

protectedinttryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

protectedbooleantryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

protectedbooleanisHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。

AQS 组件总结

Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

CountDownLatch(倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

什么是高并发

        在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在用一个处理机上运行,但任一时刻点上只有一个程序在处理机上运行。

        在互联网时代,通常指的是在某个时刻有多少访问同时进来。

需要关注的问题

1)QSP。  每秒钟请求或者查询的数量

2)吞吐量。单位时间内处理的请求数量(通常由QPS与并发数决定)

3)响应时间。从请求发出到收到响应花费的时间。

4)PV。综合浏览量(Page View),既页面浏览量或者点击量,一个访客在24小时内访问的页面数量。

5)UV。独立访客,既一定时间范围内相同访客多次访问网站,只计算为1个独立访客。

6)带宽。计算带宽大小需关注两个指标,峰值流量和页面的平均大小。

      日网站带宽 =(PV/统计时间(换算到秒))*  平均页面大小(单位KB)*  8      峰值一般是平均值的倍数,根据实际情况来定。

峰值计算公式

(总PV数 * 80%)/(6小时秒数 * 20%)= 峰值每秒请求数(QPS)

压力测试

      测试能承受的最大并发;测试最大承受的QPS值

常用的性能测试工具

ab、wrk、http_load、Web Bench、Siege、Apache Jmeter        ab全称是 apache benchmark,是Apache官方推出的工具。工作原理:创建多个并发访问线程,模拟多个访问者同时对某一URL地址进行访问。它的测试目标是基于URL的,因此,它既可以用来测试apache的负载压力,也可以测试nginx、lighthttp、tomcat、IIS等其它Web服务器的压力。

注意事项1、测试机器与被测试机器分开2、不要对线上的服务做压力测试3、测试工具ab所在机器、及被测试的前端机的CPU、内存、网络等都不超过最高限度的75%

随QPS增长的优化方案

1)QPS达到50。可以称之为小型网站,一般的服务器就可以应付

2)QPS达到100。数据库缓存层(Redis、Memcached等)、数据库的负载均衡

3)QPS达到800。CDN加速、负载均衡

4)QPS达到1000。静态HTML缓存

5)QPS达到2000。业务分离,分布式存储

解决方案案例

流量优化

防盗链处理。防止别人通过一些技术手段绕过本站的资源展示页面,盗用本站的资源,让绕开本站资源展示页面的资源链接失效。 Nginx

前端优化

1)减少HTTP请求 css文件合并、js文件合并、图片合并2)添加异步请求 3)启用浏览器缓存和文件压缩4)CDN加速5)建立独立图片服务器

服务器优化

1)页面静态化2)并发处理3)队列处理

数据库优化

1)数据库缓存2)分库分表、分区处理3)读写分离4)负载均衡

Web服务器优化

负载均衡

你可能感兴趣的:(多线程与线程并发)