并发基础、线程、线程池、线程安全、锁机制

2021/11/05

并发基础、线程、线程池、线程安全、锁机制

参考资料

一、并发基础

1.1 线程和进程

  • 线程:是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是CPU调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间。
  • 进程:进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。
  • 多线程:是多任务的一种特别的形式,但多线程使用了更小的资源开销,多线程能满足程序员编写高效率的程序来达到充分利用 CPU 的目的。

一个进程包括由操作系统分配的内存空间,包含一个或多个线程。
一个线程不能独立的存在,它必须是进程的一部分。
一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。

线程和进程的区别:

  • 1.线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  • 2.一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
  • 3.进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
  • 4.调度和切换:线程上下文切换比进程上下文切换要快得多

1.2 并行和并发:

在我们看来,都是可以同时执行多种任务,二者有什么区别?

  • 并发,从宏观方面来说,并发就是同时进行多种事件,实际上,并不是同时进行的,而是交替进行的,而由于CPU的运算速度非常的快,会造成我们的一种错觉,就是在同一时间内进行了多种事情
  • 并行,则是真正意义上的同时进行多种事情。多核cpu支持,同时进行。

1.3 AQS(Abstract Queued Synchronizer)

  • CLH同步队列:由三个人名字命名 (Craig, Landin, and Hagersten)。等待锁释放的队列,基于FIFO双端链表
  • 同步状态的获取和释放
  • 线程阻塞和唤醒

1.4 CAS(Compare And Swap)

Compare And Swap,即 比较 并 交换,是一种解决并发操作的 乐观锁
synchronized锁住的代码块:同一时刻只能由一个线程访问,属于 悲观锁

1.4.1 CAS 原理

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

优点:资源耗费少:相对于synchronized,省去了挂起线程、恢复线程的开销;
缺点:若迟迟得不到更新,死循环对CPU资源也是一种浪费;

1.4.2 缺陷:ABA问题及解决方案

在并发编程中CAS的缺点和问题,如ABA问题,自旋锁消耗问题、多变量共享一致性问题

ABA问题:
问题描述:线程t1将它的值从A变为B,再从B变为A。同时有线程t2要将值从A变为C。但CAS检查的时候会发现没有改变,但是实质上它已经发生了改变 。可能会造成数据的缺失。
解决方法:CAS还是类似于乐观锁,同数据库乐观锁的方式给它加一个【版本号】或者【时间戳】,如AtomicStampedReference

自旋消耗资源:
问题描述:多个线程争夺同一个资源时,如果自旋一直不成功,将会一直占用CPU。
解决方法:破坏掉for死循环,当超过一定时间或者一定次数时,return退出。JDK8新增的LongAddr,和ConcurrentHashMap类似的方法。当多个线程竞争时,将粒度变小,将一个变量拆分为多个变量,达到多个线程访问多个资源的效果,最后再调用sum把它合起来。

多变量共享一致性问题:
解决方法: CAS操作是针对一个变量的,如果对多个变量操作,1. 可以加锁来解决。2 .封装成对象类解决。

1.4.3 典型应用:AtomicInteger

对 i++ 与 i–,通过compareAndSet & 一个死循环实现
而compareAndSet函数内部 = 通过jni操作CAS指令。直到CAS操作成功跳出循环

1.5 DCL(Double Checked Locking)

单例模式、DCL、解决方案

1.6 线程安全性的三个体现(特性)

原子性(Atomicity)、可见性(Visibility)、有序性(Ordering):

二、多线程

2.1 线程创建

创建方式1:直接实现 Runnable 接口,重写run方法

/**
* 区别: 实现Runnable,还可以继承其他类
*/
public class ThreadImplRunble implements Runnable { 
    //实现run() 方法 ... ,run() 可以调用其他方法,使用其他类,并声明变量,就像主线程一样
} 
调用方式: new Thread(new ThreadImplRunble()).start() 运行线程

创建方式2:继承 Thread,Thread本身也是实现 implements Runnable接口,重写run方法

/**
* Thread类 本质上也是实现了 Runnable 接口的一个实例 :public class Thread  implements  Runnable{}
*/
public class MatrixThread extends Thread { 
    //重写 run() 方法 ...
}
调用方式: new MatrixThread.start() 运行线程

创建方式3:实现 Callable 接口,优点:可通过Future接收返回值,重写call方法

public class RtnThread implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {...}
}
调用方式:Future<RtnThread> future = ThreadPoolTaskExecutor.submit(new RtnThread());

创建线程的三种方式的对比

  • 1.采用实现 Runnable、Callable 接口的方式创见多线程时,线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。
  • 2.使用继承 Thread 类的方式创建多线程时,编写 使用线程简单方便些,如果需要访问当前线程,则无需使用 Thread.currentThread() 方法,直接使用 this 即可获得当前线程。

2.2 线程的生命周期

线程是一个动态执行的过程,它也有一个从产生到死亡的过程

  • 新建状态: 使用new关键字和Thread类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序start()这个线程。
  • 就绪状态: 当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。
  • 运行状态: 就绪状态的线程获取CPU资源,就可以执行run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。
  • 阻塞状态: 一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。
    • 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态,需notify/notifyAll唤醒才可以重新就绪。
    • 同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
    • 有限期阻塞:设置了timeout的wait()、 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep()状态超时,join()等待线程终止或超时,或者I/O处理完毕,线程重新转入就绪状态。
  • 死亡状态: 一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。

2.3 线程调度、优先级

线程调度:是指系统为线程分配处理器使用权的过程

调度方式:协同式线程调度、抢占式线程调度。

  • 协同式线程调度: 线程执行时间由线程本身控制,线程自己工作执行完后,主动通知系统切换到另外一个线程上。
    • 好处:实现简单,且切换操作对线程自己是可知的,没线程同步问题。
    • 坏处:线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。
  • 抢占式调度: 每个线程将由系统来分配执行时间,线程切换不由线程本身决定(Java中,Thread.yield()可以让出执行时间,但无法获取执行时间)。
    • 好处:线程执行时间系统可控,也不会有一个线程导致整个进程阻塞。

Java线程调度就是抢占式调度。

每一个 Java 线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。

  • Java 线程的优先级是一个整数,其取值范围是 1 (Thread.MIN_PRIORITY ) - 10 (Thread.MAX_PRIORITY )。
  • 默认情况下,每一个线程都会分配一个优先级 NORM_PRIORITY(5)。
  • 具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台。

2.4 线程控制(挂起、停止和恢复)

有三种方法可以使终止线程。 

  • 1.使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。 
  • 2.使用stop方法强行终止线程(这个方法不推荐使用,因为stop和suspend、resume一样,也可能发生不可预料的结果)。 
  • 3.使用interrupt方法中断线程

2.5 线程安全问题(多个线程对临界区、共享的内存数据脏读)

多个线程操作同一块内存数据,会造成数据混乱,就是常说的线程安全问题

  • 线程安全:当多个线程访问一个对象时,如果不用考虑这些线程的调度和交替执行,也不需要进行额外的同步,或者其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的
  • 线程不安全:不提供数据访问时的数据保护,多个线程能够同时操作某个数据,从而出现数据不一致或者数据污染的情况。

2.6 线程间通信、并发交互模型

线程间的通信方式

  • 1.synchronized 互斥同步:本质上就是“共享内存”式的通信。多个线程需要访问同一个共享变量,谁拿到了对象锁(获得了访问权限),谁就可以执行
  • 2.while轮询:会浪费CPU资源 容易导致死锁
  • 3.wait/notify机制: CPU的利用率提高 通知过早,会打乱程序的执行逻辑。
  • 4.管道通信:就是使用java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信

多线程常用交互模型 –4种并发模型

  • 1.生产者-消费者模型
  • 2.读-写-锁模型
  • 3.Future模型
  • 4.Worker-Thread模型

2.7 线程死锁: DeadLocks

两个以上的线程被无限的阻塞,线程之间相互等待所需的资源

以下的情况发生死锁:

  • 当两个线程相互调用Thread.join();
  • 当两个线程使用嵌套的同步块时,一个线程占用了另一个线程的必需的锁,互相等待时被阻塞,就有可能出现死锁。

死锁的发生必需满足4个必要条件:

  • 互斥
  • 持有并等待
  • 非抢占
  • 形成等待环。

2.8 多线程的使用

有效利用多线程的关键是理解程序是并发执行而不是串行执行的。

例如:程序中有两个子系统需要并发执行,这时候就需要利用多线程编程。

  • 通过对多线程的使用,可以编写出非常高效的程序。
  • 如果创建太多线程,程序执行的效率实际上是降低了,而不是提升了。 上下文的切换开销也很重要,如果创建了太多的线程,CPU 花费在上下文的切换的时间将多于执行程序的时间!

三、线程池 (属于对象池,最大程度的重复利用线程)

线程池:一个或多个线程的集合

3.1 为什么用线程池

有时,系统需要处理非常多的执行时间很短的请求,如果每一个请求都开启一个新线程的话,系统就要不断的进行线程的创建和销毁,花在创建和销毁线程上的时间可能会比线程真正执行的时间还长。

使用线程池主要为了解决一下几个问题:

  • 通过重用线程池中的线程,来减少每个线程创建和销毁的性能开销。
  • 对线程进行一些维护和管理,比如定时开始,周期执行,并发数控制等等

线程池能有效的处理多个线程的并发问题,避免大量的线程因为互相强占系统资源导致阻塞现象,能够有效的降低频繁创建和销毁线程对性能所带来的开销。

3.2 四种常见的线程池

  • CachedThreadPool:可缓存的线程池,该线程池中没有核心线程,非核心线程的数量为Integer.max_value,就是无限大,当有需要时创建线程来执行任务,没有需要时回收线程,适用于耗时少,任务量大的情况。
  • SecudleThreadPool:周期性执行任务的线程池,按照某种特定的计划执行线程中的任务,有核心线程,但也有非核心线程,非核心线程的大小也为无限大。适用于执行周期性的任务。
  • SingleThreadPool:只有一条线程来执行任务,适用于有顺序的任务的应用场景。
  • FixedThreadPool:定长的线程池,有核心线程,核心线程即为最大的线程数量,没有非核心线程
Executors.newCachedThreadPool();
Executors.newFixedThreadPool();
Executors.newScheduledThreadPool();
Executors.newSingleThreadExecutor();
Executors.newSingleThreadScheduledExecutor(); //创建一个可以【周期性】执行任务的【单线程】线程池
Executors.newWorkStealingPool(); //1.8新增,任务窃取线程池,用的是ForkJoinPool,之前的线程池中,多个线程共有一个阻塞队列,而newWorkStealingPool 中每一个线程都有一个自己的队列。当线程发现自己的队列没有任务了,就会到别的线程的队列里获取任务执行。可以简单理解为”窃取

3.3 线程池参数

Java提供了创建线程池的类:Executor,创建时,一般使用它的子类:ThreadPoolExecutor,配置不同的参数配置来创建线程池

启动线程池,分配线程任务

public ThreadPoolExecutor(int corePoolSize,  //是线程池中的核心线程数量,这几个核心线程,在没有用的时候,也不会被回收  
                          int maximumPoolSize,  //是线程池中可以容纳的最大线程的数量
                          long keepAliveTime,  //是线程池中除了核心线程之外的其他的最长可以保留的时间,因为在线程池中,除了核心线程即使在无任务的情况下也不能被清除,其余的都是有存活时间的,意思就是非核心线程可以保留的最长的空闲时间
                          TimeUnit unit, //是计算这个时间的一个单位 
                          BlockingQueue<Runnable> workQueue,  //是等待队列,任务可以储存在任务队列中等待被执行,执行的是FIFO原则
                          ThreadFactory threadFactory,  //创建线程的线程工厂
                          RejectedExecutionHandler handler)  //是一种拒绝策略,我们可以在任务满了后,拒绝或执行某些任务

3.4 线程池主要处理流程

  • 核心线程可执行?:任务进来时,首先判断核心线程是否处于空闲状态,如果不满,核心线程就先执行任务,
  • 队列可存放?:如果核心线程已满,则判断任务队列是否有空闲存放该任务,若果有,就将任务保存在任务队列中,等待执行
  • 非核心线程可执行?:如果队列已满了,再判断最大可容纳的线程数,如果没有超出这个数量,就开创非核心线程执行任务,
  • 拒绝策略:如果超出了最大可容纳的线程数,就调用handler实现拒绝策略。

3.5 任务缓存队列及排队策略

在前面我们多次提到了任务缓存队列,即workQueue,它用来存放等待执行的任务。
workQueue的类型为BlockingQueue,通常可以取下面三种类型:

  • 1)ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;
  • 2)LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
  • 3)synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。

3.6 任务拒绝策略

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

  • ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
  • ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
  • ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务

3.7 线程池的关闭

ThreadPoolExecutor提供了两个方法,用于线程池的关闭,分别是shutdown()和shutdownNow(),其中:

  • shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务
  • shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务

3.8 线程池容量的动态调整

ThreadPoolExecutor提供了动态调整线程池容量大小的方法:setCorePoolSize()和setMaximumPoolSize(),

  • setCorePoolSize:设置核心池大小
  • setMaximumPoolSize:设置线程池最大能创建的线程数目大小

当上述参数从小变大时,ThreadPoolExecutor进行线程赋值,还可能立即创建新的线程来执行任务。

3.9 线程池大小规则

  • 一些进程绝大多数时间在计算上,称为计算密集型(CPU密集型)computer-bound。
  • 有一些进程则在input 和output上花费了大多时间,称为I/O密集型,I/O-bound。

一般说来,大家认为线程池的大小经验值应该这样设置:(其中N为CPU的个数)

  • CPU密集型应用,则线程池大小设置为N+1
  • IO密集型应用,则线程池大小设置为2N+1

如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。
但是,IO优化中,这样的估算公式可能更适合:
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

下面举个例子:
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)8=32。这个公式进一步转化为:
最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)
CPU数目

刚刚说到的线程池大小的经验值,其实是这种公式的一种估算值。

四、锁机制(线程同步控制方式,线程安全的实现方法)

4.0 线程安全的实现方法、锁的本质

线程安全的实现方法:互斥同步(sychronized、lock/ReentrantLock)、非阻塞同步 (基于冲突检测的乐观并发策略)、无同步方案 三种方案

方案对比:

  • 互斥同步是一种悲观的并发策略,认为不做同步就会出错,访问共享数据时不论是否存在竞争都加锁,最主要的问题是进行线程阻塞和唤醒时都要陷入内核,开销较大。
  • 实现非阻塞同步需要硬件支持,通常就是通过CAS(Compare And Swap)原语来实现的。
  • 不涉及共享数据,自然就无须任何同步措施去保证正确性,线程自然是安全的;

锁的本质:
一个标记:每个线程都能看到

锁要解决的大概就只有这4个问题:

  • “谁拿到了锁“这个信息存哪里(可以是当前class,当前instance的markword,还可以是某个具体的Lock的实例)
  • 谁能抢到锁的规则(只能一个人抢到 - Mutex;能抢有限多个数量 - Semphore;自己可以反复抢 - 重入锁;读可以反复抢到但是写独占 - 读写锁……)
  • 抢不到时怎么办(抢不到玩命抢;抢不到暂时睡着,等一段时间再试/等通知再试;或者二者的结合,先玩命抢几次,还没抢到就睡着)
  • 如果锁被释放了还有其他等待锁的怎么办(不管,让等的线程通过超时机制自己抢;按照一定规则通知某一个等待的线程;通知所有线程唤醒他们,让他们一起抢……)

4.1 互斥同步 (悲观的并发策略)

互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段。

互斥同步对性能最大的影响:是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力

互斥实现方式:临界区(CriticalSection)、 互斥量(Mutex)和信号量(Semaphore)

互斥

  • 互斥就是不同线程通过竞争进入临界区(共享的数据和硬件资源),为了防止访问冲突,在有限的时间内只允许其中之一独占性的使用共享资源。如不允许同时写
  • 互斥就是线程A访问了一组数据,线程BCD就不能同时访问这些数据,直到A停止访问
  • 互斥是通过竞争对资源的独占使用,彼此之间不需要知道对方的存在,执行顺序是一个乱序。

同步

  • 同步就是ABCD这些线程要约定一个执行的协调顺序,比如D要执行,B和C必须都得做完,而B和C要开始,A必须先得完成
  • 同步关系则是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。如先生产后使用
  • 同步是协调多个相互关联线程合作完成任务,彼此之间知道对方存在,执行顺序往往是有序的。

4.1.1 sychronized(重量级、互斥、悲观锁)来保证线程的同步

synchronized实现线程安全原理:
JVM 是通过建立一个Monitor ,进入、退出对象监视器( Monitor )来实现对方法、同步块的同步

  • Monitor可以是要修改的变量,也可以是其他自己认为合适的对象(方法);
  • 然后通过给这个Monitor加互斥锁,每个线程在获得这个锁之后,要执行完加载load到working memory 到 use && 指派assign 到 存储store 再到 main memory的过程。才会释放它得到的锁。这样就实现了所谓的线程安全
  • 而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。

Synchronized对象锁和类锁区别

  • 对象锁:synchronized修饰 实例方法 / 代码块
  • 类锁:synchronized修饰 类、静态方法 / 静态代码块

类上加锁(.class 位于代码区,静态方法位于静态区域,这个类产生的对象公用这个静态方法,所以这块 内存,N个对象来竞争), 这时候,只要是这个类产生的对象,在调用这个静态方法时都会产生互斥

4.1.2 Lock、ReentrantLock控制并发 / 线程同步

Lock实现原理:volitile 修饰 int 变量state,每个线程都拥有对state的可见性和原子修改

4.2 非阻塞同步 (基于冲突检测的乐观并发策略)

通俗地说,认为多个线程进行争用是很少发生的;

具体就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)

4.3 无同步方案

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。

同步只是保证共享数据争用时的正确性的手段;

如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性;

4.4 锁相关

synchronized 很多都称之为重量锁,JDK1.6 中对 synchronized 进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁和轻量锁,自旋锁、适应性自旋锁、锁消除、锁粗化。

4.4.1 java对象头

主要包括两部分数据:Mark Word(标记字段,内有锁标记位)、Klass Pointer(类型指针,确定对象是哪个类的实例)。

对象头信息是与对象自身定义的数据无关的额外存储成本

  • Klass Point 是对象指向它类元数据的指针,虚拟机通过该指针确定这个对象是哪个类的实例
  • Mark Word,每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息,是实现轻量级锁和偏向锁的关键

4.4.2 锁主要存在四中状态

  • 无锁状态、
  • 偏向锁状态、
  • 轻量级锁状态、
  • 重量级锁状态。

他们会随着竞争的激烈而逐渐升级。 注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

4.4.3 CPU的两种工作状态:内核态,用户态

  • 内核态: 系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行。内核态可以使用计算机所有的硬件资源。
  • 用户态:不能直接使用系统资源,也不能改变CPU的工作状态,并且只能访问这个用户程序自己的存储空间

4.4.4 偏向锁(无实际锁竞争,偏向于第一个访问锁的线程)

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,进一步提高程序的运行性能;

偏向锁的特征是:

  • 锁不存在多线程竞争,并且应由一个线程多次获得锁。
  • 当线程访问同步块时,会使用 CAS 将线程 ID 更新到锁对象的 Mark Word 中,如果更新成功则获得偏向锁,并且之后每次进入这个对象锁相关的同步块时都不需要再次获取锁了。
  • 偏向锁只适用于没有锁争用场景,一旦发现有另一线程在争用锁,jvm就会撤销偏向锁;

释放偏向锁:
当有另外一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来判定将对象头中的 Mark Word 设置为无锁或者是轻量锁状态。

偏向锁可以提高带有同步却没有竞争的程序性能,但如果程序中大多数锁都存在竞争时,那偏向锁就起不到太大作用。可以使用 -XX:-UseBiasedLocking 来关闭偏向锁,并默认进入轻量锁。

4.4.5 轻量锁(允许短时间的很轻锁竞争,不需要申请互斥量)

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

  • 当多个线程对锁的竞争很轻,比如多个线程交替进入临界区,或者说稍微等待一下(自旋),另一个线程就会释放锁,此时jvm会使用轻量级锁。轻量级锁避免了传统锁的挂起线程和唤醒线程的开销。
  • 当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁

当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record)区域,同时将锁对象的对象头中 Mark Word 拷贝到锁记录中,再尝试使用 CAS 将 Mark Word 更新为指向锁记录的指针。
如果更新成功,当前线程就获得了锁。
如果更新失败 JVM 会先检查锁对象的 Mark Word 是否指向当前线程的锁记录。
如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。
不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量锁。

轻量锁的解锁:
轻量锁的解锁过程也是利用 CAS 来实现的,会尝试锁记录替换回锁对象的 Mark Word 。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁)

轻量锁能提升性能的原因是:
认为大多数锁在整个同步周期都不存在竞争,所以使用 CAS 比使用互斥开销更少。但如果锁竞争激烈,轻量锁就不但有互斥的开销,还有 CAS 的开销,甚至比重量锁更慢。

4.4.6 重量级锁(有实际锁竞争,且锁竞争时间长)

为了换取性能,JVM在内置锁上做了非常多的优化,膨胀式的锁分配策略就是其一;

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

  • 锁对象已经被其他线程抢占了。在CAS失败的情况下,虚拟机会尝试多次CAS(即自旋),若自旋多次都失败,那轻量级锁就不再有效,要膨胀为重量级锁

总结:

4.4.7 锁膨胀(发生锁竞争,锁升级的过程)

4.4.8 锁优化: 自旋锁、锁消除、锁粗化

自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大压力。许多应用上面,对象锁的锁状态只会持续很短一段时间,为这一段很短时间频繁地阻塞和唤醒线程是非常不值得。所以引入自旋锁。

所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋) 自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

适应性自旋锁
在使用 CAS 时,如果操作失败,CAS 会自旋再次尝试。由于自旋是需要消耗 CPU 资源的,所以如果长期自旋就白白浪费了 CPU。JDK1.6加入了适应性自旋: 如果某个锁自旋很少成功获得,那么下一次就会减少自旋

锁消除(去掉没必要加的锁)
指虚拟机”即时编译器”在运行时,对一些代码上要求同步,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
锁消除即删除不必要的加锁操作,提高程序的性能。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

锁粗化(扩大锁作用范围)
使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,但是如果是对同一个对象反复加锁和解锁,甚至是在循环体中不停的加锁解锁操作的、一系列的连续加锁 解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁,以减少加锁和解锁的次数,从而提高性能。

4.4.9 可重入锁、公/非平锁、悲/乐观锁、互斥锁、内置锁、分布式锁

可重入锁 ReentrantLock

  • 避免当前以及获取锁的线程再次获取该锁的时候,进入阻塞状态。 进行一次判断是否是自己获取了该锁,判断正确后,再次获取该锁,每次再次获取该锁都需要对state值进行自增,在释放的时候,需要释放相应的次数。

公平锁:FairSync

  • 解释:当前资源被加锁后,其他线程按照请求先后顺序搁置到queue中,当锁被释放掉,然后严格按照先进先出(FIFO)的原则一个一个加锁。
  • 应用:(ReentrantLock lock = new ReentrantLock(true) //true构造函数是公平锁

非公平锁:NonfairSync (效率高)

  • 解释:不管其他线程,先尝试给共享资源加锁,如果加锁成功就阻塞其他线程(因为其他线程都在队列中排队,这个时候就特别的霸道而显得不公平), 如果是共享资源上已经被加锁了,这个时候还要再判断下能不能加锁,两次尝试加锁都失败再霸道也没用了,就只能到等锁队列尾部排队!
  • 应用:ReentrantLock lock = new ReentrantLock() //默认非公平锁

悲观锁:多写场景

  • 解释:认为别人会修改,每次操作都加锁。
  • 应用:
    • 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
    • Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁:多读场景

  • 解释:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,
  • 实现方式:版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量
  • 应用:
    • 数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
    • J.U.C.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

独占锁:

  • 解释:一次只能被一个线程访问;
  • 应用:ReadWritLock的写锁是独占锁)

共享锁:

  • 解释:可以被多个线程持有;
  • 应用:ReadWritLock的读锁是共享锁

内置锁:

  • 解释:多线程的锁,其实本质上就是给一块内存空间的访问添加访问权限,因为Java中是没有办法直接对某一块内存进行操作的,又因为Java是面向对象的语言,一切皆对象,所以具体的表现就是某一个对象承担锁的功能,每一个对象都可以是一个锁。
  • 应用:使用 synchronized 关键字,synchronized 方法或者 synchronized 代码块。

互斥锁:

  • 互斥锁、独占锁、内置锁:并没有“同步锁”这个名词,Java的synchronized正确的叫法应该是“互斥锁”,“独占锁”或者“内置锁”。但有的人“顾名思义”叫它同步锁。

信号量:

  • 把互斥锁推广到”N”的空间,同时允许有N个线程进入临界区的锁叫“信号量”。互斥量和信号量的实现都依赖TSL指令保证“检查-占锁”动作的原子性。

临界区:

  • 涉及读写竟态资源的代码片段叫“临界区”。

管程:

  • 把互斥量交给程序员使用太危险,有些编程语言实现了“管程”的特性,从编译器的层面保证了临界区的互斥,比如Java的synchronized关键字。

分段锁:

  • 分段锁其实是一种锁的设计,并不是具体的一种锁,分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中每个元素是一个链表;同时又是一个ReentrantLock(Segment继承ReentrantLock)。 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

分布式锁:
https://www.cnblogs.com/seesun2012/p/9214653.html
https://www.cnblogs.com/lijiasnong/p/9952494.html

  • 理解:实现多进程间互斥同步的标记,多进程可见
  • 实现:
1.数据库实现方式
    基于表字段版本号做分布式锁
    基于数据库排他锁做分布式锁
2.Redis实现方式
    基于 REDIS 的 SETNX()、EXPIRE() 做分布式锁
    基于 REDIS 的 SETNX()、GET()、GETSET()做分布式锁
    基于 REDLOCK 做集群模式的 Redis 分布式锁
3.zookeeper实现方式


4.5 多线程同步控制示例

例:演示了100个线程同时向一个银行账户中存入1元钱,在没有使用同步机制和使用同步机制情况下的执行情况

1.没有同步情形  
银行账户类:
/**  银行账户 */
public class Account {
    private double balance;     // 账户余额

    /** 存款  */
    public void deposit(double money) {
        double newBalance = balance + money;
        try {
            Thread.sleep(10);   // 模拟此业务需要一段处理时间
        }
        catch(InterruptedException ex) {
            ex.printStackTrace();
        }
        balance = newBalance;
    }

    /** 获得账户余额  */
    public double getBalance() {
        return balance;
    }
}

/** 存钱线程 */
public class AddMoneyThread implements Runnable {
    private Account account;    // 存入账户
    private double money;       // 存入金额

    public AddMoneyThread(Account account, double money) {
        this.account = account;
        this.money = money;
    }

    @Override
    public void run() {
        account.deposit(money);
    }

}

测试类:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test01 { 
    public static void main(String[] args) {
        Account account = new Account();
        ExecutorService service = Executors.newFixedThreadPool(100); 
        for(int i = 1; i <= 100; i++) {
            service.execute(new AddMoneyThread(account, 1));
        } 
        service.shutdown(); 
        while(!service.isTerminated()) {} 
        System.out.println("账户余额: " + account.getBalance());
    }
}
结论: 执行结果通常是显示账户余额在10元以下,出现这种状况的原因是,当一个线程A试图存入1元的时候,另外一个线程B也能够进入存款的方法中,线程B读取到的账户余额仍然是线程A存入1元钱之前的账户余额,因此也是在原来的余额0上面做了加1元的操作

解决这个问题的办法就是同步,当一个线程对银行账户存钱时,需要将此账户锁定,待其操作完成后才允许其他的线程进行操作

方式1.在银行账户的存款(deposit)方法上同步(synchronized)关键字
/**   银行账户   */
public class Account {
    private double balance;     // 账户余额

    /** 存款:在银行账户的存款(deposit)方法上同步(synchronized)关键字 */
    public synchronized void deposit(double money) {
        double newBalance = balance + money;
        try {
            Thread.sleep(10);   // 模拟此业务需要一段处理时间
        }
        catch(InterruptedException ex) {
            ex.printStackTrace();
        }
        balance = newBalance;
    }

    /** 获得账户余额 */
    public double getBalance() {
        return balance;
    }
}

方式2.  在线程调用存款方法时对银行账户进行同步
/**  存钱线程 */
public class AddMoneyThread implements Runnable {
    private Account account;    // 存入账户
    private double money;       // 存入金额

    public AddMoneyThread(Account account, double money) {
        this.account = account;
        this.money = money;
    }

    @Override
    public void run() {        //在线程调用存款方法时对银行账户进行同步
        synchronized (account) {
            account.deposit(money); 
        }
    }
}

方式3.通过Java 5显示的锁机制,为每个银行账户创建一个锁对象,  在存款操作进行加锁和解锁的操作
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**  银行账户 */
public class Account {
    private Lock accountLock = new ReentrantLock();   //为每个银行账户创建一个锁对象,
    private double balance; // 账户余额

    /**  存款  */
    public void deposit(double money) {
        accountLock.lock();  //在存款操作进行加锁和解锁的操作
        try {
            double newBalance = balance + money;
            try {
                Thread.sleep(10); // 模拟此业务需要一段处理时间
            }
            catch (InterruptedException ex) {
                ex.printStackTrace();
            }
            balance = newBalance;
        }
        finally {
            accountLock.unlock();  //在存款操作进行加锁和解锁的操作
        }
    }

    /** 获得账户余额 */
    public double getBalance() {
        return balance;
    }
} 

五、JUC并发包

问题总结

1.Thread类的sleep()方法和对象的wait()方法都可以让线程暂停执行,它们有什么区别?

  • sleep()(休眠):是线程类(Thread)的静态方法,会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,休眠结束后自动恢复到就绪状态
  • wait():是Object类方法,线程放弃对象锁(线程暂停执行),进入对象等待池(wait pool),

只有调用对象的notify()方法(或notifyAll()方法)时才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。

2.线程的sleep()方法和yield()方法有什么区别?

  • sleep()方法给其他线程运行机会时不考虑线程的优先级;
    • yield()方法只会给相同优先级或更高优先级的线程以运行的机会;
  • 线程执行sleep()方法后转入阻塞(blocked)状态,
    • yield()方法后转入就绪(ready)状态;
  • sleep()方法声明抛出InterruptedException,
    • yield()方法没有声明任何异常;
  • sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性。

3.当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?
不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。

因为非静态方法上的synchronized修饰符要求执行方法时要获得对象的锁,如果已经进入A方法说明对象锁已经被取走,那么试图进入B方法的线程就只能在等锁池(注意不是等待池哦)中等待对象的锁。

4.与线程同步以及线程调度相关的方法。

  • wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
  • sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理InterruptedException异常;
  • notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且与优先级无关;
  • notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争对象的锁,只有获得锁的线程才能进入就绪状态;

5.Lock、Condition、信号量机制

  • 1).显式锁机制(explicit lock),灵活对线程的协调. Lock中定义加锁lock()和解锁unlock()方法,
  • 2).同时还提供了newCondition()方法来产生用于线程之间通信的Condition对象
  • 3).Java 5还提供信号量机制(semaphore),用来限制某个共享资源进行访问的线程数量。

在对资源进行访问之前,线程必须得到信号量的许可(调用Semaphore对象的acquire()方法);
在完成对资源的访问后,线程必须向信号量归还许可(调用Semaphore对象的release()方法)。

Post Directory

扫码关注公众号:暂无公众号
发送 290992
即可立即永久解锁本站全部文章