【java多线程基础篇】● 学习总结

(持续更新)

文章目录

并发编程的基础概念

进程(Process)与线程(Thread)

进程和线程是最常提到的概念了。在linux中,线程与进程最大的区别就是是否共享同一块地址空间,而且共享同一块地址空间的那一组线程将显现相同的PID号。下面介绍下两者的概念:

  • 进程是操作系统进行资源分配和调度的最小单元,可以简单地理解为系统中运行的一个程序就是一个进程。
  • 线程是CPU调度的最小单元,是进程中的一个个执行流程。
  • 一个进程至少包含一个线程,可以包含多个线程,这些线程共享这个进程的资源(比如堆区和方法区资源)。同时每个线程都拥有独立的运行栈和程序计数器,线程切换开销小。
  • 多进程指的是操作系统同时运行多个程序,如当前操作系统中同时运行着QQ、IE、微信等程序。
  • 多线程指的是同一进程中同时运行多个线程,如迅雷运行时,可以开启多个线程,同时下载多个文件。
    在这里插入图片描述
    在这里插入图片描述

并行(Parallel)、并发(Concurrent)

  • 并发:是指多个线程任务在同一个CPU上快速地轮换执行,由于切换的速度非常快,给人的感觉就是这些线程任务是在同时进行的,但其实并发只是一种逻辑上的同时进行;
  • 并行:是指多个线程任务在不同CPU上同时进行,是真正意义上的同时执行。

我们发现并行编程中,很重要的一个特点是系统具有多核CPU。要是系统是单核的,也就谈不上什么并行编程了。

线程安全

有人这样解释:如果线程的随机调度顺序不影响某段代码的最后执行结果,那么我们认为这段代码是线程安全的。
为了保证代码的线程安全,Java中推出了很多好用的工具类或者关键字,比如volatile、synchronized、ThreadLocal、锁、并发集合、线程池和CAS机制等。这些工具并不是在每个场景下都能满足我们多线程编程的需求,并不是在每个场景下都有很高的效率,需要我们程序员根据具体的场景来选择最适合的技术,这也许就是我们程序员存在的价值所在。
一般我们对共享变量进行并发修改时,就会遇到线程安全问题。原子性问题、可见性问题和有序性问题

死锁

线程1占用了锁A,等待锁B,线程2占用了锁B,等待锁A,这种情况下就造成了死锁(更加书面的解释:死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去)。

饥饿

饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。

在自然界中,母鸟给雏鸟喂食时很容易出现这种情况:由于雏鸟很多,食物有限,雏鸟之间的食物竞争可能非常厉害,经常抢不到食物的雏鸟有可能会被饿死。线程的饥饿非常类似这种情况。

此外,某一个线程一直占着关键资源不放,导致其他需要这个资源的线程无法正常执行,这种情况也是饥饿的一种。与死锁相比,饥饿还是有可能在未来一段时间内解决的(比如,高优先级的线程已经完成任务,不再疯狂执行)。

活锁

活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。 活锁和 死锁 的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
活锁是一种非常有趣的情况。不知道大家是否遇到过这么一种场景,当你要坐电梯下楼时,电梯到了,门开了,这时你正准备出去。但很不巧的是,门外一个人挡着你的去路,他想进来。于是,你很礼貌地靠左走,避让对方。同时,对方也非常礼貌地靠右走,希望避让你。结果,你们俩就又撞上了。于是乎,你们都意识到了问题,希望尽快避让对方,你立即向右边走,同时,他立即向左边走。结果,又撞上了!不过介于人类的智能,我相信这个动作重复两三次后,你应该可以顺利解决这个问题。因为这个时候,大家都会本能地对视,进行交流,保证这种情况不再发生。

但如果这种情况发生在两个线程之间可能就不会那么幸运了。如果线程的智力不够,且都秉承着“谦让”的原则,主动将资源释放给他人使用,那么就会导致资源不断地在两个线程间跳动,而没有一个线程可以同时拿到所有资源正常执行。这种情况就是活锁。

同步(Synchronous)和异步(Asynchronous)

这边讨论的同步和异步指的是同步方法和异步方法。

同步方法是指调用这个方法后,调用方必须等到这个方法执行完成之后才能继续往下执行。
异步方法是指调用这个方法后会立马返回,调用方能立马往下继续执行。被调用的异步方法其实是由另外的线程进行执行的,如果这个异步方法有返回值的话可以通过某种通知的方式告知调用方。

实现异步方法的方式:

回调函数模式:一个方法被调用后立马返回,调用结果通过回调函数返回给调用方;
MQ(发布/订阅):请求方将请求发送到MQ,请求处理方监听MQ处理这些请求,并将请求处理结果也返回给某个MQ,调用方监听这个Queue获取处理结果;
多线程处理模式:系统创建其他线程处理调用请求,比如Spring中的 @Async注解标注的方法就是这种方法。

临界区

临界区就是在同一时刻只能有一个任务访问的代码区。
就是导致竞态发生的代码区。
或者说你上锁的代码区。

上下文切换

线程在CPU上运行之前需要CPU给这个线程分配时间片,当时间片运行完之后这个线程就会让出CPU资源给其他的线程运行。但是线程在将CPU资源让出之前会保存当前的任务状态以便下次获得CPU资源之后可以继续往下执行。所以线程从保存当前执行状态到再加载的过程称为一次上下文切换。
减少上下文切换的措施

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

扩展:一文看懂临界区、互斥锁、同步锁、临界区、信号量、自旋锁等名词!

线程

线程三种创建方法

在Java中有多种方式可以实现多线程编程(记得这是一道常问的面试题,特别是在应届生找工作的时候被问的频率就更高了)。

  • 继承Thread类并重写run方法;
  • 实现Runnable接口,并将这个类的实例当做一个target构造Thread类;
  • 实现Callable接口;

继承Thread类,实现Runnable接口 两种方式都可以很方便地实现多线程编程。但是这两种方式也有几个很明显的缺陷:

  • 没有返回值:如果想要获取某个执行结果,需要通过共享变量等方式,需要做更多的处理。
  • 无法抛出异常:不能声明式的抛出异常,增加了某些情况下的程序开发复杂度。
  • 无法手动取消线程:只能等待线程执行完毕或达到某种结束条件,无法直接取消线程任务。
    为了解决以上的问题,在JDK5版本的java.util.concurretn包中,引入了新的线程实现机制:Callable接口。
@FunctionalInterface
public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

扩展1.Callable、Future和FutureTask 关系

(1)Callable是Runnable的封装的异步运算任务

(2)Future用来保存Callable异步运算的结果

(3)FutureTask封装Future的实体类

使用Callable接口的例子:

/**
 *创建 newFixedThreadPool 线程池
 *创建Callable接口的类实例并提交到 线程池pool中
 *
 */
public class MyThread {

    public static final int THREAD_COUNT = 5;

    public static void main(String[] args) throws Exception {

        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        Runner runner = new Runner();

        for (int i = 0; i < THREAD_COUNT; i++) {
            Future<Integer> submit = executorService.submit(runner);
            //get方法会一直阻塞等到线程执行结束
            System.out.println(submit.get());
        }
        executorService.shutdown();

    }


    public static class Runner implements Callable<Integer> {

        @Override
        public Integer call() throws Exception {
            System.out.println("my thread name is:"+Thread.currentThread().getName());
            Random random = new Random();
            int sleepTime = random.nextInt(500);
            try {
                TimeUnit.SECONDS.sleep(sleepTime);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                System.out.println(Thread.currentThread().getName()+" end after "+sleepTime+" seconds");
            }
            return sleepTime;
        }
    }

}

线程属性(常用方法)

Thread类常用的方法如下:

Thread.activeCount():这个方法用于返回当前线程的线程组中活动线程的数量,返回的值只是一个估计值,因为当此方法遍历内部数据结构时,线程数可能会动态更改。)。
Thread.checkAccess(): 检验当前正在执行的线程是否有权限修改thread的属性,这个方法我们一般不自己进行调用,Thread类的set方法在进行属性修改时都会先调用这个方法。 Thread.currentThread():获取当前正在运行的线程。
Thread.dumpStack():输出线程栈,一般在debug的时候调用。 Thread.enumerate(Thread tarray[]):??使用场景。
Thread.getAllStackTraces():获取系统中所有线程的线程栈信息。
thread.getName():获取线程的名字。 thread.getPriority():获取线程的优先级。
thread.getStackTrace():获取堆栈信息。 thread.getState():获取线程状态。
thread.getThreadGroup():获取线程所在线程组。
thread.interrupt():使得指定线程中断阻塞状态,并将阻塞标志位置为true。 thread.interrupted():测试当前线程是否被中断。
thread.isAlive():判断线程是否还存活着。
thread.isDaemon():判断线程是否是守护线程。
thread.join():在当前线程中加入指定线程,使得当前线程必须等待指定线程运行结束之后,才能结束。可以理解成线程插队、等待该线程终止。
Thread.sleep(long):强制线程睡眠一段时间。
thread.start():启动一个线程。
thread.setName(name):设置线程的名字。
thread.setPriority(priority):设置线程的优先级。
thread.setDaemon(true):将指定线程设置为守护线程。
thread.yield():使得当前线程退让出CPU资源,把CPU调度机会分配给同样线程优先级的线程。
object.wait()、object.notify()、object.notifyAll():Object类提供的线程等待和线程唤醒方法。

wait-notify模式的经典写法(等待/通知机制)

wait-notify模式可以实现生产者消费者。
生产者和消费者的逻辑都可以统一抽象成以下几个步骤:

  • step1:获得对象的锁;
  • step2:循环判断是否需要进行生产活动,如果不需要进行生产就调用wait方法,暂停当前线程;如果需要进行生产活动,进行对应的生产活动;
  • step3:通知等待线程

伪代码如下:

synchronized(对象) {
    //这边进行循环判断的原因是为了防止伪唤醒,也就是不是消费线程或者生产线程调用notify方法将waiting线程唤醒的
    while(条件){
        对象.wait();
    }
    //进行生产或者消费活动
    doSomething();
    对象.notifyAll();
}

线程状态

在Java中,一个线程从创建到消亡会经历
新建状态(New)、就绪状态(Runnable)、运行状态(Running)、等待(Waiting)、阻塞状态(Blocked)和死亡状态,共六种状态。
线程状态在源码中时一个枚举类 java.lang.Thread.State。

请添加图片描述

sleep() 与 wait()

①sleep()释放CPU执行权,但不释放同步锁;

②wait()释放CPU执行权,也释放同步锁,使得其他线程可以使用同步控制块或者方法。

线程池

每次我们执行异步操作,你还在用new Thread吗?
这已经out了。
我们可以使用线程池。
同时阿里巴巴在其《Java开发手册》中也强制规定:线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
相比new Thread,Java提供的四种线程池的好处在于:

      a. 重用存在的线程,减少对象创建、消亡的开销,性能佳。
      b. 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
      c. 提供定时执行、定期执行、单线程、并发数控制等功能。

下图是线程池大概的实现原理。
在这里插入图片描述
线程池的创建方式总共包含以下 7 种(其中 6 种是通过 Executors 创建的,1 种是通过 ThreadPoolExecutor 创建的):
注意:前6种底层其实都是new ThreadPoolExecutor实现的,通过更改其中参数来达到不同的控制效果。学透ThreadPoolExecutor,其它的不也就迎刃而解吗。

  1. Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待;
  2. Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程;
  3. Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序;
  4. Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池;
  5. Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池;
  6. Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)【JDK 1.8 添加】。
  7. ThreadPoolExecutor:最原始的创建线程池的方式,它包含了 7 个参数可供设置。

在这里插入图片描述
他们再Java包中的结构如下图所示,Executors,扮演的是线程工厂的角色,一定要与Executor相区分开,Executors只是Executor框架中的一个工厂类而已,通过Executors我们可以创建特定功能的线程池(ThreadPoolExecutor)。
在这里插入图片描述
ThreadPoolExecutor提供了四个构造方法:
在这里插入图片描述
我们以最后一个构造方法(参数最多的那个),对其参数进行解释:
在这里插入图片描述
(源码)

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

在这里插入图片描述
线程池对任务的处理流程,用个图总结一下
在这里插入图片描述

参数详解点这里

阿里巴巴《Java开发手册》推荐使用ThreadPoolExecutor 创建线程:

【强制要求】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors 返回的线程池对象的弊端如下:

1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

所以综上情况所述,我们推荐使用 ThreadPoolExecutor 的方式进行线程池的创建,因为这种创建方式更可控,并且更加明确了线程池的运行规则,可以规避一些未知的风险。
线程池的7种创建方式,强烈推荐你用它…

Java内存模型JMM

JMM(Java Memory Model)
JMM主要是为了规定了线程和内存之间的一些关系。根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有实例变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(Working Memory)工作内存由缓存和堆栈两部分组成,缓存中保存的是主存中变量的拷贝,缓存可能并不总和主存同步,也就是缓存中变量的修改可能没有立刻写到主存中;堆栈中保存的是线程的局部变量,线程之间无法相互直接访问堆栈中的变量。

JMM是什么

JMM(Java Memory Model)是Java内存模型,JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节。

为什么要设计JMM

屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

Java多线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的。如下图
JMM内存模型
面试官:那JMM定义了什么
这个简单,整个Java内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可
见性,有序性。这三个特征可谓是整个Java并发的基础。

JMM的三个特征

Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的,那我们依次看一下这三个特征:

原子性(Atomicity)

一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

可见性:

一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。

有序性:

对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn
Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
    在这里插入图片描述

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
重排序会遵循as-if-serial与happens-before原则

内存屏障指令

内存屏障是一种可以使编译器和处理器强制执行排序的指令,这样内存屏障一侧的指令就不会被重新排序到屏障的另一侧。
内存屏障会提供3个功能:
(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

(2)它会强制将对缓存的修改操作立即写入主存;

(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

volatile就借助了内存屏障来帮助其解决可见性和有序性问题,还为其带来了一个禁止指令重排的附加功能。

as-if-serial语义

不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
简单说就是,as-if-serial语义保证了单线程中,不管指令怎么重排,最终的执行结果是不能被改变的。

先行发生(happens-before)原则

缓存一致性协议(MESI)

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。

缓存加锁

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32和Intel 64处理器使用MESI实现缓存一致性协议。

JMM八种数据原子操作(内存交互操作)

原子操作指令在这里插入图片描述

锁技术

锁的分类

Java中锁分为以下几种:

乐观锁、悲观锁
独享锁、共享锁
公平锁、非公平锁
互斥锁、读写锁
可重入锁
分段锁
锁升级(无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁) JDK1.6

轻量级同步机制: 关键字volatile

volatile 的主要作用有两点:

  • 保证变量的可见性
  • 禁止指令重排序

volatile的实现原理

通过上面的介绍,我们知道volatile可以实现内存的可见性和防止指令重排序。那么volatile的这些功能是怎么实现的呢?其实volatile的这些内存语意是通过内存屏障技术实现的。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。同时内存屏障还能保证内存的可见性。

关于内存屏障的具体内容,要讲的话需要花很大的篇幅来讲解。这边就不具体展开了。大家感兴趣的可以自己了解下。

volatile变量的禁止指令重排序

volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。
JMM层面的“内存屏障”:

LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:

LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
-------

StoreStoreBarrier
volatile 写操作
StoreLoadBarrier

volatile使用总结

  • volati是Java提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),volatile的实现原理是基于处理器的Lock指令的,这个指令会使得对变量的修改立马刷新回主内存 ( 缓存一致性协议 ) ,同时使得其他CPU中这个变量的副本失效
  • volatile对于单个的共享变量的读/写(比如a=1;这种操作)具有原子性,但是像num++或者a=b;这种复合操作,volatile无法保证其原子性
  • volatile的使用场景不是很多,使用时需要深入考虑下当前场景是否适用volatile(记住“对变量的写操作不依赖于当前值”、“该变量没有包含在具有其他变量的不变式中”这两个使用条件)。常见的使用场景有多线程下的状态标记量和双重检查等

众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说volatile就会比synchronized快上多少。
如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。
我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。

同步锁(可重入锁)——ReentrantLock

ReentrantLock锁
ReentrantLock是Java中常用的锁,属于乐观锁类型,多线程并发情况下。能保证共享数据安全性,线程间有序性
ReentrantLock通过原子操作和阻塞实现锁原理,一般使用lock获取锁,unlock释放锁,
下面说一下锁的基本使用和底层基本实现原理,lock和unlock底层

lock的时候可能被其他线程获得所,那么此线程会阻塞自己,关键原理底层用到Unsafe类的API: CAS和park

常用方式(伪代码)
//创建锁对象
ReentrantLock lock = new ReentrantLock();

lock.lock(); //获取锁(锁定)
一段需要上锁的代码 
lock.unlock(); //锁释放

使用ReentrantLock
Java中的ReentrantLock锁

内部锁:关键字 synchronized

它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。

1.1 synchronized介绍

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。

synchronized可以修饰普通方法,静态方法和代码块。当synchronized修饰一个方法或者一个代码块的时候,它能够保证在同一时刻最多只有一个线程执行该段代码。可以保障原子性,可见性,有序性。无论是对一个对象进行加锁还是对一个方法进行加锁,实际上,都是对对象进行加锁。

  • 对于普通同步方法,锁是当前实例对象(不同实例对象之间的锁互不影响)。

  • 对于静态同步方法,锁是当前类的Class对象。

  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

Synchronized实现原理(Synchronized那一块儿代码发生么什么?)

在这里插入图片描述
在.class(字节码)文件中:

public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter  		//进入监视器,进入同步方法
4: aload_0
5: dup
6: getfield      #2             // Field i:I
9: iconst_1
10: iadd
11: putfield      #2            // Field i:I
14: aload_1
15: monitorexit 		  //注意此处,监视器退出,退出同步方法
16: goto          24
19: astore_2
20: aload_1
21: monitorexit 		//注意此处,监视器退出,退出同步方法
22: aload_2
23: athrow
24: return
Exception table:
//省略其他字节码.......

可以看出同步方法块在进入代码块时插入了monitorentry语句,在退出代码块时插入了monitorexit语句,为了保证不论是正常执行完毕(第15行)还是异常跳出代码块(第21行)都能执行monitorexit语句,因此会出现两句monitorexit语句。

在多线程运行过程中,线程会去先抢对象的监视器,这个监视器是对象独有的,其实就相当于一把钥匙,抢到了,那你就获得了当前代码块儿的执行权。

其他没有抢到的线程会进入队列(SynchronizedQueue)当中等待,等待当前线程执行完后,释放锁.

最后当前线程执行完毕后通知出队然后继续重复当前过程.

从 jvm 的角度来看 monitorenter 和 monitorexit 指令代表着代码的执行与结束。

Synchronized锁升级

synchronized的锁升级,说白了,就是当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级。
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的。得到锁的线程能访问同步资源。

锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,这四种锁状态分别代表什么,为什么会有锁升级?其实在 JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁,但是在JDK 1.6后,Jvm为了提高锁的获取与释放效率对(synchronized )进行了优化,引入了 偏向锁 和 轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
假如我们一开始就知道某个同步代码块的竞争很激烈、很慢的话,那么我们一开始就应该使用重量级锁了,从而省掉一些锁转换的开销。

锁的四种状态

JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,其中synchronized的锁状态只能升级,不能降级

如图所示:
在这里插入升级过程图片描述
在这里插入图片描述

锁状态对比
锁状态存储内容标志位
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁(自旋)指向栈中锁记录的指针01
重量级锁指向互斥量的指针11
![锁 优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到索竞争的线程,使用自旋会消耗CPU 追求响应速度,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较慢](https://img-blog.csdnimg.cn/55a7488efd0242b69fab88c289f2831d.png)

为什么说重量级锁开销大呢

主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
这就是说为什么重量级线程开销很大的。
JDK6以前 synchronized只有重量级锁,这也是它效率低下的原因,JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

互斥锁(重量级锁)也称为阻塞同步、悲观锁

既然synchronized有锁升级那么有锁降级吗?

在 HotSpot 虚拟机中是有锁降级的,但是仅仅只发生在 STW 的时候,只有垃圾回收线程能够观测到它,也就是说,在我们正常使用的过程中是不会发生锁降级的,只有在 GC 的时候才会降级。

ReentrantLock和Synchronized的区别

① 底层实现
② 是否可手动释放:
③ 是否可中断
④ 是否公平锁
synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法 new ReentrantLock() 时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。
⑤ 锁是否可绑定条件Condition
⑥ 锁的对象
详解看:谈谈synchronized与ReentrantLock的区别

  • 其实ReentrantLock和Synchronized最核心的区别就在于Synchronized适合于并发竞争低的情况,因为Synchronized的锁升级如果最终升级为重量级锁在使用的过程中是没有办法消除的,意味着每次都要和cpu去请求锁资源,而ReentrantLock主要是提供了阻塞的能力,通过在高并发下线程的挂起,来减少竞争,提高并发能力,所以我们文章标题的答案,也就显而易见了。

  • synchronized是一个关键字,是由jvm层面去实现的,而ReentrantLock是由java api去实现的。

  • synchronized是隐式锁,可以自动释放锁,ReentrantLock是显式锁,需要手动释放锁。

  • ReentrantLock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

  • ReentrantLock可以获取锁状态,而synchronized不能。

Sychronized与Lock的区别

在这里插入图片描述

ReentrantLock synchronized 应用场景的两个问题

为什么不用ReentrantLock而用synchronized ?
  • 减少内存开销:如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
  • 内部优化:synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。
动态高并发时为什么推荐ReentrantLock而不是Synchronized

synchronized不可以锁降级,所以当synchronized升级到重量级锁的时候,效率就比较低了。

锁的优化及注意事项

有助于提高锁性能的几点建议

1 减少锁持有时间

对于使用锁进行并发控制的应用程序来说,如果单个线程特有锁的时间过长,会导致锁的竞争更加激烈,会影响系统的性能.

就是尽量让临界区的代码块运行的时间短,以此来降低单个线程持有锁的时间,降低锁的竞争程度。

2减小锁的粒度

一个锁保护的共享数据的数量大小称为锁的粒度.如果一个锁保护的共享数据的数量大就称该锁的粒度粗,否则称该锁的粒度细.锁的粒度过粗会导致线程在申请锁时需要进行不必要的等待。减少锁粒度是一种削弱多线程锁竞争的一种手段。
java8的ConcurrentHashMap为何放弃分段锁?

3使用读写分离锁代替独占锁

使用ReadWriteLock,读写分离锁可以提高系统性能,使用读写分离锁也是减小锁粒度的一种特殊情况.第二条建议是能分割数据结构实现减小锁的粒度,那么读写锁是对系统功能点的分割.
在多数情况下都允许多个线程同时读,在写的使用采用独占锁,在读多写少的情况下,使用读写锁可以大大提高系统的并发能力。

4锁分离

将读写锁的思想进一步延伸就是锁分离.读写锁是根据读写操作功能上的不同进行了锁分离.根据应用程序功能的特点,也可以对独占锁进行分离.如java.util.concurrent.LinkedBlockingQueue类中take()与put()方法分别从队头取数据,把数据添加到队尾.虽然这两个方法都是对队列进行修改操作,由于操作的主体是链表,take()操作的是链表的头部,put()操作的是链表的尾部,两者并不冲突.如果采用独占锁的话,这两个操作不能同时并发,在该类中就采用锁分离,take()取数据时有取锁, put()添加数据时有自己的添加锁,这样take()与put()相互独立实现了并发.

5锁粗化

为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短.但是凡事都有一个度,如果对同一个锁不断的进行请求,同步和释放,也会消耗系统资源.

JVM在遇到一连串对同一个锁进行请求和释放操作时,会自动优化,将这些锁整合成对锁的一次请求。这叫锁的粗化。
我们在编程中也要有这样的思想。

扩展之java8的ConcurrentHashMap为何放弃分段锁

jdk1.7分段锁的实现

和hashmap一样,在jdk1.7中ConcurrentHashMap的底层数据结构是数组加链表。和hashmap不同的是ConcurrentHashMap中存放的数据是一段段的,即由多个Segment(段)组成的。每个Segment中都有着类似于数组加链表的结构,每一段都加了一个锁。

关于Segment

ConcurrentHashMap有3个参数:

  1. initialCapacity:初始总容量,默认16
  2. loadFactor:加载因子,默认0.75
  3. concurrencyLevel:并发级别,默认16

其中并发级别控制了Segment的个数,在一个ConcurrentHashMap创建后Segment的个数是不能变的,扩容过程过改变的是每个Segment的大小。

关于ConcurrentHashMap中的分段锁

段Segment继承了重入锁ReentrantLock,有了锁的功能,每个锁控制的是一段,当每个Segment越来越大时,锁的粒度就变得有些大了。

  • 分段锁的优势在于保证在操作不同段 map 的时候可以并发执行,操作同段 map 的时候,进行锁的竞争和等待。这相对于直接对整个map同步synchronized是有优势的。
  • 缺点在于分成很多段时会比较浪费内存空间(不连续,碎片化); 操作map时竞争同一个分段锁的概率非常小时,分段锁反而会造成更新等操作的长时间等待; 当某个段很大时,分段锁的性能会下降。

参考:
并发编程系列博客传送门 作者:程序员自由之路
透彻理解Java并发编程 作者:Ressmix

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜鸟猫喵喵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值