面试之敌系列 3 多线程详解

官方定义:线程是CPU调度和分配的基本单位,一定要和进程操作系统进行资源分配(包括cpu、内存、磁盘IO等)的最小单位区别清楚。注意,一个是cpu的,一个是系统的资源(这里的资源表示除了CPU 之外的一切东西,也叫上下文)
CPU进程无法同时刻共享,但是出现一定要共享CPU的需求呢?此时线程的概念就出现了。线程被包含在进程当中,进程的不同线程间共享CPU和程序上下文。(共享进程分配到的资源)。

单CPU进行进程调度的时候,需要读取上下文+执行程序+保存上下文,即进程切换。如果这个CPU是单核的话,那么在进程中的不同线程为了使用CPU核心,则会进行线程切换,但是由于共享了程序执行环境,这个线程切换比进程切换开销少了很多。在这里依然是并发,唯一核心同时刻只能执行一个线程。
如果这个CPU是多核的话,那么进程中的不同线程可以使用不同核心,真正的并行出现了。

线程的出现是为了降低上下文切换的消耗,提高系统的并发性,并突破一个进程只能干一样事的缺陷,使到进程内并发成为可能。例如一个程序里面,如果有很多的功能,但是他是单线程的,也就是一个功能一个功能的串行执行,这样其实对cpu 的利用率不是很高的,因为如果此时还会发生一些阻塞的时候,这时候分配给这个程序的时间其实就浪费了。为此,又将进程的各部分功能采用线程的方式来实现,这样,同样是这个程序在运行的时候,由于是多线程的方式执行的,此时可以并发地(单核)甚至并行(多核)地执行。这样,就可以减少一个进程的运行时间,进程的上下文切换就会减少。进程层面:用户进程在请求Redis、Mysql数据等网络IO阻塞掉的时候,或者在进程时间片到了,都会引发上下文切换。
一个应用程序会有多个进程,如果此时的进程是最小的CPU调度单位的话,那么对于一个共享的资源的读写都是需要进行进程的切换的,相反的,如果此时有一种模式,可以针对共享的资源,使得上下文的切换的开销最小,那么此时就是线程了。
引起上下文切换的情况
1. 不同进程间的线程的切换。
2. 不同的进程切换。

在同一进程中的各个线程,都可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量机构等。由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核。
复制代码

进程和程序

进程

进程是允许某个并发的程序在某个数据集上的运行过程

一般来说,进程由正文段,用户数据和进程控制块共同组成。其中,正文段主要是机器指令,用户数据主要是进行可以直接操作的用户数据。进程控制块是一种数据结构,用于描述和控制进程运行时候的各种状态。

  1. 并发性。 多个进程实体能在一段时间间隔内同时运行。并发性是进程和现代操作系统的重要特征。

  2. 动态性。 进程是进程实体的执行过程。进程的动态性表现在因执行程序而创建进程、因获得CPU而执行进程的指令、因运行终止而被撤销的动态变化过程。此外,进程在创建后还有进程状态的变化。

  3. 独立性。 在没有引入线程概念的操作系统中,进程是独立运行和资源调度的基本单位。

  4. 异步性。 是指进程的执行时断时续,进程什么时候执行、什么时候暂停都无法预知,呈现一种随机的特性。

进程是程序的一次执行,而进程总是对应至少一个特定的程序。一个程序可以对应多个进程,同一个程序可以在不同的数据集合上运行,因而构成若干个不同的进程。几个进程能并发地执行相同的程序代码,而同一个进程能顺序地执行几个程序。

PCB

  • 进程标志符号,用于唯一的标志一个进程。
  • 处理机状态:记录个处理机,各个寄存器内该进程运行时候的数据
  • 进程调度信息:进程状态,进程优先级,事件等。主要和进程的状态改变有关的属性。
  • 进程控制信息:程序和数据的地址嘻嘻,同步和通信机制等。

进程上下文

是进程执行活动全过程的静态描述。包括计算机系统中与执行该进程有关的各种寄存器的值、程序段在经过编译之后形成的机器指令代码集、数据集及各种堆栈值和PCB结构。可按一定的执行层次组合,如用户级上下文、系统级上下文等。

  • 上文: 把已执行的进程指令和数据在相关寄存器与堆栈中的内容称为上文。
  • 正文: 把正在执行的进程指令和数据在相关寄存器与堆栈中的内容称为正文。
  • 下文: 把待执行的进程指令和数据在相关寄存器与堆栈中的内容称为下文。

Unix System Ⅴ 的进程上下文组成:由用户级上下文、寄存器上下文、系统级上下文组成。

  • 用户级上下文:由进程的用户程序段部分编译而成的用户正文段、用户数据和用户栈等组成。

  • 寄存器上下文:由程序计数器(PC)、处理机状态字(PS)、栈指针和通用寄存器组成。 PC给出CPU将要执行的下一条指令的虚地址;PS给出机器与该进程相关联时的硬件状态;栈指针指向下一项的当前地址;通用寄存器则用于不同执行模式之间的参数传递。

  • 系统级上下文又分为静态部分和动态部分。 这里的动态部分是指进入和退出不同的上下文层次时,系统为各层上下文中相关联的寄存器值所保存和恢复的记录。 系统级上下文静态部分包括PCB结构、将进程虚地址空间映射到物理空间的有关表格、核心栈等。 这里,核心栈主要用来装载进程中所使用的系统调用的调用序列。

  • 系统级上下文的动态部分是与寄存器上下文相关联的。进程上下文的层次概念主要体现在动态部分中,即系统级上下文的动态部分可看成是由一些数量变化的层次组成,其变化规则符合许先进后出的堆栈方式。

进程上下文切换

进程上下文切换发生在不同的进程之间而不是同一个进程内。 进城上下文切换分成三个步骤: (1) 把被切换进程的相关信息保存到有关存储区,例如该进程的PCB中。 (2) 操作系统中的调度和资源分配程序执行,选取新的进程。 (3) 将被选中进程的原来保存的正文部分从有关存储区中取出,并送至寄存器与堆栈中,激活被选中进程执行。

原子性,可见性,有序性

CPU 的线程切换和

注意,线程任何时候都会面临CPU时间结束或者被剥夺的时候,也就是线程在执行完一些指令之后(还没完成所有的指令),会被切换出CPU的使用。这里就会发生线程的上下文切换。但是!!!!这时候是不会释放锁的,也就是即使你在某个地方无法获得CPU继续执行,但是这部分的代码只有你能在未来获得CPU之后继续执行,其他的线程是无法执行的(因为没有释放锁!!!)。这个就是原子性。

原子性

  1. 原子操作:指的是不可分割的最小操作指令。例如基本类型的赋值语句等。但是很多的时候,我们需要多条指令其情况下的原子操作。
  2. 多条指令的原子操作:一组操作要么不执行,要么全部执行不会被打断(这里的打断指的是任何时候永远只有一个线程来执行其实的所有语句。同步的概念在里面。)
  3. 打断:其他线程同一时刻访问该代码块。

有序性

指的是程序执行的时候按照代码编写的先后顺序。 有序性的保证

  1. volatile
  2. Lock或者synchronized关键字

指令重排

as-if-searies 语义可以保证,不管是否发生指令重排,单线程的程序执行的结果必须是一致的。为了保证这个,但发生数据的依赖的时候,有依赖的数据操作指令一般不会重排。

happens-before 的程序规则

  1. 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unlock 操作先 发生于后面的lock
  3. volatile变量规则:对一个变量的写操作先发生于后面对这个变量的读操作
  4. 传递:如果A先于B ,B先于C 那么 A先于C

程序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。 也就是as-if-searies其实是保证单线程的安全性的。

锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。

volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

CPU指令重排

由于CPU是流水线作业的,因此,单条的CPU指令,其实也是分成很多的步骤,例如取指,译码,执行,访问存储器,写寄存器

面试之敌系列 3 多线程详解

一条指令,按时序,一次经过各个流水线完成。例如绿色的指令,先经历了取指令,译码,运行,写回寄存器等四个流水线。

可见性

当有多个线程并发访问同一个资源的时候,一个线程的修改,其他的线程可以立刻知道。 这里涉及到两个:

  1. 一个线程对volatile修饰的变量的修改和写回去是一个原子操作。
  2. 会对其他线程中该值的缓存设置失效(导致其他线程中此时的资源更新。)但是依据该变量计算得到的新的数值不会改变。因为这已经成为历史了。例如 b=a+1;已经计算过,那么及时a已经更新。b也不会再次更新了!!

可见性并不能保证线程安全。i=0;因为对于非原子操作i++,仍然可能会存在修改覆盖的情况。i++其实会分成:

  1. 读取i的值(如果其他线程更新,那么这里也会由于缓存导致更新,总是i的感受是实时更新的。因此,volatile经常用于某些标志位的修饰,使得标志位可以实时更新。)
  2. 计算i+1的值(这里不再更新)
  3. 将i+1的值赋值给i。(赋值之后会立刻刷回主存,同时通知缓存失效。)

由于单纯的可见性,没有原子性,那么此时会发生另外一个线程重入,例如在3执行之前,另外的一个线程已经将i的值修改完成。此时i=1,那么线程更新i的值,但是赋值的时候回i=1,因此这次的修改逻辑是是错误的。

volatile 其实最大的用处就是

  1. 修饰标志位,使得线程可以实时监测该状态
  2. 修饰某个变量,使得对一些操作避免指令重排。例如 instance = new Object(), i++等操作。i++比较特殊,因为它防止了指令重排,先计算 i+1的值,后面再赋值,但是i+i的值不再更新了。因此还是会有线程安全问题。

多线程的实现和区别

继承Thread类

通过定义自己的类并继承Thread类,同时重写run()方法,可以直接获得一个线程实例。
复制代码
public class ThreadDemo01 extends Thread{
        public ThreadDemo01(){
            //编写子类的构造方法,可缺省
            }
        public void run(){
            //编写自己的线程代码
            System.out.println(Thread.currentThread().getName());
            }
        public static void main(String[] args){ 
            ThreadDemo01 threadDemo01 = new ThreadDemo01(); 
            threadDemo01.setName("我是自定义的线程1");
            threadDemo01.start();       
            System.out.println(Thread.currentThread().toString());  
            }
    }
复制代码
需要注意的一个点就是需要使用start()方法来启动一个线程,直接调用run方法是无法实现线程的效果的。start()方法会调用start0(),这是一个native方法。而且,start方法是一个加了synchronize 的方法,可以同步的创建线程。如果直接调用run方法的话,就相当于直接调用一个普通方法。
继承的缺点:
复制代码
    1. 由于java是单继承的模式,因此继承了Thread之后就无法继承其他的类,会存在局限性。
    1. 网上很多说Thread无法做到资源共享???一脸懵,Thread 类自己就已经实现了Runnable 接口了呀。
      面试之敌系列 3 多线程详解

实现Runnable接口

通过Tread 创建一个线程类的实例的时候,构造方法有一种为 Thread(Runnnable arg);因此,可以传进入一个实现Runnable接口的实例来获得一个线程类实例,而且此时可以做到资源共享。多个线程实例可以共享同一个runnable 实例的资源。但是这里会出现多线程共享资源时候的同步问题,需要自己做同步操作。
复制代码
public class ThreadDemo02 {

    public static void main(String[] args){ 
        System.out.println(Thread.currentThread().getName());
        Thread t1 = new Thread(new MyThread());
        t1.start(); 
    }
}

class MyThread implements Runnable{
    @Override
    public void run() {
        // TODO Auto-generated method stub
        System.out.println(Thread.currentThread().getName()+"-->我是通过实现接口的线程实现方式!");
    }   
}

复制代码

通过同一个Runnable 实例,可以实现资源共享的多线程并发。因为传入 Thread的Runnable实例是同一个实例,共享该实例的所有资源。

线程池方式

创建线程池主要有三个静态方法供我们使用,由Executors来进行创建相应的线程池:

public static ExecutorSevice newFixedThreadPool(int nThreads)
public static ExecutorSevice newCachedThreadPool()
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) 

复制代码
  • newSingleThreadExecutor 返回以个包含单线程的Executor,将多个任务交给此Exector时,这个线程处理完一个任务后接着处理下一个任务,若该线程出现异常,将会有一个新的线程来替代。
  • newFixedThreadPool返回一个包含指定数目线程的线程池,如果任务数量多于线程数量,那么没有执行的任务必须等待,直到有任务完成为止。
  • newCachedThreadPool根据用户的任务数创建相应的线程来处理,该线程池不会对线程数目加以限制,完全依赖于JVM能创建线程的数量,可能引起内存不足。
  • newScheduledThreadPool创建一个至少有n个线程空间大小的线程池。此线程池支持定时以及周期性执行任务的需求。

我们只需要把实现了Runnable的类的对象实例放入线程池,那么线程池就自动维护线程的启动、运行、销毁。我们不需要自行调用start()方法来开启这个线程。线程放入线程池之后会处于等待状态直到有足够空间时会唤醒这个线程。我们只需要将实现了runnable接口的实例给到线程池就行。

private ExecutorService threadPool = Executors.newFixedThreadPool(5);
threadPool.execute(socketThread);

复制代码

详解线程池

// Java线程池的完整构造函数
public ThreadPoolExecutor(
  int corePoolSize, // 线程池长期维持的线程数,即使线程处于Idle状态,也不会回收。
  int maximumPoolSize, // 线程数的上限
  long keepAliveTime, TimeUnit unit, // 超过corePoolSize的线程的idle时长,
                                     // 超过这个时间,多余的线程会被回收。
  BlockingQueue<Runnable> workQueue, // 任务的排队队列
  ThreadFactory threadFactory, // 新线程的产生方式
  RejectedExecutionHandler handler) // 拒绝策略

复制代码
面试之敌系列 3 多线程详解

线程池默认的拒绝行为是AbortPolicy,也就是抛出RejectedExecutionHandler异常,该异常是非受检异常,很容易忘记捕获。如果不关心任务被拒绝的事件,可以将拒绝策略设置成DiscardPolicy,这样多余的任务会悄悄的被忽略。

线程池对一个新来任务的执行流程

面试之敌系列 3 多线程详解

execute 方法执行逻辑有这样几种情况:

1. 如果当前运行的线程少于 corePoolSize,则会创建新的线程来执行新的任务;
2. 如果运行的线程个数等于或者大于 corePoolSize,则会将提交的任务存放到阻塞队列 workQueue 中;
3. 如果当前 workQueue 队列已满的话,则会创建新的线程来执行任务;
4. 如果线程个数已经超过了 maximumPoolSize,则会使用饱和策略 RejectedExecutionHandler 来进行处理。
复制代码

需要注意的是,线程池的设计思想就是使用了核心线程池 corePoolSize,阻塞队列 workQueue 和线程池 maximumPoolSize,这样的缓存策略来处理任务,实际上这样的设计思想在需要框架中都会使用。

面试之敌系列 3 多线程详解

线程的生命周期

线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过 新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直"霸占"着CPU独自运行,所以CPU需要在多条线程之间切换,于是 线程状态也会多次在运行、阻塞之间切换。

New 新建

当程序使用new关键字创建了一个线程之后,该线程就处于 新建状态,此时的线程情况如下:

  • 此时JVM为其分配内存,并初始化其成员变量的值;
  • 此时线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体;

就绪(Runnable)状态

线程对象调用了start()方法之后,该线程处于 就绪状态。此时的线程情况如下:

  1. 此时JVM会为其 创建方法调用栈和程序计数器;
  2. 该状态的线程一直处于 线程就绪队列(尽管是采用队列形式,事实上,把它称为可运行池而不是可运行队列。因为CPU的调度不一定是按照先进先出的顺序来调度的),线程并没有开始运行;
  3. 此时线程 等待系统为其分配CPU时间片,并不是说执行了start()方法就立即执行;

注意:

  1. 调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体;
  2. 需要指出的是,调用了线程的run()方法之后,该线程已经不再处于新建状态,不要再次调用线程对象的start()方法。只能对处于新建状态的线程调用start()方法,否则将引发IllegaIThreadStateExccption异常;

运行(Running)状态

当CPU开始调度处于 就绪状态 的线程时,此时线程获得了CPU时间片才得以真正开始执行run()方法的线程执行体,则该线程处于 运行状态。

处于运行状态的线程最为复杂,它 不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。线程状态可能会变为 阻塞状态、就绪状态和死亡状态。比如:对于采用 抢占式策略 的系统而言,系统会给每个可执行的线程分配一个时间片来处理任务;当该时间片用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。线程就会又 从运行状态变为就绪状态,重新等待系统分配资源;

阻塞(Blocked)状态

处于运行状态的线程在某些情况下,让出CPU并暂时停止自己的运行,进入 阻塞状态。 发生如下情况时,线程将会进入阻塞状态:

面试之敌系列 3 多线程详解
  1. 线程调用sleep()方法,主动放弃所占用的处理器资源,暂时进入中断状态(不会释放持有的对象锁),时间到后等待系统分配CPU继续执行;
  2. 线程调用一个阻塞式IO方法,在该方法返回之前,该线程被阻塞;
  3. 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有;
  4. 程序调用了线程的suspend方法将线程挂起;
  5. 线程调用wait,等待notify/notifyAll唤醒时(会释放持有的对象锁);

线程基本操作

join

join方法可以看做是线程间协作的一种方式,很多时候,一个线程的输入可能非常依赖于另一个线程的输出,这就像两个好基友,一个基友先走在前面突然看见另一个基友落在后面了,这个时候他就会在原处等一等这个基友,等基友赶上来后,就两人携手并进。其实线程间的这种协作方式也符合现实生活。在软件开发的过程中,从客户那里获取需求后,需要经过需求分析师进行需求分解后,这个时候产品,开发才会继续跟进。如果一个线程实例A执行了threadB.join(),其含义是:当前线程A会等待threadB线程终止后threadA才会继续执行。关于join方法一共提供如下这些方法:

主动调用的线程会先执行,执行完之后在继续执行当前的线程。例如 在A中 使用 B.join 此时会先执行B 执行完成之后再执行 A
复制代码

sleep

public static native void sleep(long millis)方法显然是Thread的静态方法,很显然它是让当前线程按照指定的时间休眠,其休眠时间的精度取决于处理器的计时器和调度器。需要注意的是如果当前线程获得了锁,sleep方法并不会失去锁。sleep方法经常拿来与Object.wait()方法进行比价,这也是面试经常被问的地方。

sleep() VS wait()
- sleep()方法是Thread的静态方法,而wait是Object实例方法
- wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。而sleep()方法没有这个限制可以在任何地方种使用。另外,wait()方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁;
- sleep()方法在休眠时间达到后如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notift/Object.notifyAll通知后,才会离开等待池,并且再次获得CPU时间片才会继续执行。
复制代码

yield

public static native void yield();这是一个静态方法,一旦执行,它会是当前线程让出CPU,但是,需要注意的是,让出的CPU并不是代表当前线程不再运行了,如果在下一次竞争中,又获得了CPU时间片当前线程依然会继续运行。另外,让出的时间片只会分配给当前线程相同优先级的线程。什么是线程优先级了?下面就来具体聊一聊。 现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当前时间片用完后就会发生线程调度,并等待这下次分配。线程分配到的时间多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要或多或少分配一些处理器资源的线程属性。

另外需要注意的是,sleep()和yield()方法,同样都是当前线程会交出处理器资源,而它们不同的是,sleep()交出来的时间片其他线程都可以去竞争,也就是说都有机会获得当前线程让出的时间片。而yield()方法只允许与当前线程具有相同优先级的线程能够获得释放出来的CPU时间片。

监视器实现Monitor

深入分析synchronize

CAS :并发编程中,锁是消耗性能的操作,同一时间只能有一个线程进入同步块修改变量的值。如果不加 synchronized 的话,多线程修改 a 的值就会导致结果不正确,出现线程安全问题。但锁又是要给耗费性能的操作。不论是拿锁,解锁,还是等待锁,阻塞,都是非常耗费性能的。那么能不能不加锁呢?

CAS详解:操作系统里面实现了cas 其中的 (expect==old){old=new;} 是原子操作。但是即使如此,它还是无法解决ABA问题,因为在读取完old的数值之后,old的数值可能会先发生A-B-A的变换,此时进行到原子操作 cas,这是也是会成功的。但是old的值其实是改变过了。

CAS可以保证数值在高并发的时候的正确性,但是付出了多次尝试,也就是自旋的代价,此时是会浪费一定的CPU性能的。

  1. CAS 是非阻塞的,轻量级的乐观锁。
  2. CAS是CPU指令
  3. CAS是原子操作,保证的是并发的安全性,而不是同步性。

CAS 底层

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

复制代码

cas底层是通过cmpxch(x,addr,e)来实现的。这个函数会对多核的处理器进行锁操作,使得每次都只有一个线程来执行这两个指令,从而实现原子性。

锁机制有如下两种特性:

互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。

可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。

对象锁

在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。

类锁

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。

synchronize 获取的锁的类型

  1. synchronize修饰的是普通代码块和非静态方法的时候是对象锁

  2. synchronize修饰的是静态方法和类的class 实例的时候(类.class),是类锁

  3. synchronize 中可能使用到的有关锁的字段

    面试之敌系列 3 多线程详解

偏向锁

如果一个对象处于偏向锁的状态,这就表示这个偏向锁科能是当前线程的,也可能是其他线程的。

  1. 首先通过比较对象头中的Thread_ID 来确定是不是自己的锁。注意,如果线程ID比较不一致,并不会导致直接升级为偏向锁,因为偏向锁是不会自动撤销的。也就是说一个线程执行完成之后,对象头中的Thread_ID 的数值不会自动的置零!!!。因此你不知道这个Thread_ID 对应的线程是否还在工作。偏向锁的释放采用了 一种只有竞争才会释放锁的机制(出现了竞争才会执行锁的释放过程,释放过程是包括在了锁的撤销流程中的。锁的释放就是该线程已经退出了同步代码区,此时可以将word Mark 区域恢复为无锁状态。也就是一开始的混沌开始状态),线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要 等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
    1. 暂停拥有偏向锁的线程;
    2. 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;
  2. 如果是的话,就可以继续执行代码块;否则进入步骤3
  3. 如果此时不是自己的锁,那么尝试进行CAS获取锁,如果成功了,则将Thread_ID设置为自身的ID,并开始执行同步代码块。偏向锁的CAS是cas(word_mark_addr, null, current_threadid)。也就是预期的值是null/0的时候才会cas成功。如果此时已经被偏向过了,是不会成功的。需要进行后续的撤销操作。
    面试之敌系列 3 多线程详解
    面试之敌系列 3 多线程详解
    面试之敌系列 3 多线程详解

总结就是,一开始使用偏向锁之后,如果没有新的线程出现的话,那么锁可以不断的重入,但是不用释放,而且只有在第一次使用的时候使用了CAS。之后不用进行任何操作。

但是如果出现了新的线程执行CAS的话,由于偏向锁不会主动的释放锁,需要等待竞争的到来才会释放锁。因此,竞争的线程进行CAS的时候,会发现Thread_ID已经不为初始值 null 了,此时一定会失败,触发其撤销的机制。撤销的过程中会判断线程是否存活,存活的话是否会继续竞争等。再对对应的转态标志位进行修改。

如果在撤销阶段发现竞争是存在的,那么此时会执行锁膨胀,由偏向锁转变为轻量级锁。全局安全点就是没有执行指令的时候。处理完成之后,会恢复被暂停的线程继续执行。在升级为轻量级锁之前,持有偏向锁的线程(线程 A)是暂停的,JVM 首先会在原持有偏向锁的线程(线程 A)的栈中创建一个名为锁记录的空间(Lock Record),用于存放锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 到原持有偏向锁的线程(线程 A)的锁记录中(官方称之为 Displaced Mark Word ),这时线程 A 获取轻量级锁,此时 Mark Word 的锁标志位为 00,word lock(word Mark 里面的一个字段??)指向线程 A 的锁记录地址,如下图:

面试之敌系列 3 多线程详解

当原持有偏向锁的线程(线程 A)获取轻量级锁后,JVM 唤醒线程 A,线程 A 执行同步代码块,执行完成后,开始轻量级锁的释放过程。 对于其他线程而言,也会在栈帧中建立锁记录,存储锁对象目前的 Mark Word 的拷贝。JVM 利用 CAS 操作尝试将锁对象的 Mark Word 更正指向当前线程的 Lock Record,如果成功,表明竞争到锁,则执行同步代码块,如果失败,那么线程尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。当然,它不会一直自旋下去,因为自旋的过程也会消耗 CPU,而是自旋一定的次数,如果自旋了一定次数后还是失败,则升级为重量级锁,阻塞所有未获取锁的线程,等待释放锁后唤醒。 轻量级锁的释放,会使用 CAS 操作将 Displaced Mark Word 替换会对象头中,成功,则表示没有发生竞争,直接释放。如果失败,表明锁对象存在竞争关系,这时会轻量级锁会升级为重量级锁,然后释放锁,唤醒被挂起的线程,开始新一轮锁竞争,注意这个时候的锁是重量级锁。

轻量级锁

自适应自旋锁

自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

自适应自旋解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。

自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。


顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅_将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功_,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。

轻量级锁的CAS 也是无状态的时候执行将word mark 更新为指向本线程中Lock Recoder 的指针,如果更新成功,那么此时获得锁,将锁的标志设置为轻量级锁。
如果CAS失败,那么表示此时已经被别的线程获取了锁,先比较是否是自身获取了锁。如果是指向自身Lock Recoder 的指针,那么就是一次重入。如果不是,那么就自旋几次。如果自旋几次都失败了,那么就改变了Word Mark 的标志位为重量级锁(word Mark 会指向一个monitor对象。此时锁的管理已经已交给了monitor了。注意,一个对象会关联一个监视器来进行同步管理),并且自己会进入阻塞队列中。
此时,获得锁的线程会执行完毕,完成锁的释放。也就是将锁设置为无锁状态,但是此时CAS的时候回发现,此时的锁已经是重量级锁,由于不一致,因此表明有线程已经阻塞了。这时候就执行重量级锁的释放即可。释放锁,并唤醒阻塞的线程即可。
复制代码
面试之敌系列 3 多线程详解

简单总结

面试之敌系列 3 多线程详解
偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。     一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。       轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
两个CAS 的预期值是已知的,偏向锁是:偏向=1|线程ID=null|01 。撤销锁的时候可以实现word Mark重置
轻量级锁的CAS 的预期值是 偏向=0|锁标志位=00|锁记录指针=null CAS的目的就是将所记录的指针指向自己的线程中锁记录地址。轻量级锁会自动的释放锁,释放的流程就是将预期值cas设置到 word Mark中。
复制代码
面试之敌系列 3 多线程详解

锁原理:偏向锁、轻量锁、重量锁1.加锁2.撤销偏向锁1.加锁2.解锁3.膨胀为重量级锁

Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

JVM-锁消除+锁粗化 自旋锁、偏向锁、轻量级锁 逃逸分析-30

CAS操作、Java对象头、偏向锁的获取与撤销、轻量级锁的获取与撤销、锁粗化、锁消除

从偏向锁是如何升级到重量级锁的

啃碎并发(七):深入分析Synchronized原理

重量级锁

注意,synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量),作用粒度是对象 ,可以用来实现对 临界资源的同步互斥访问 ,是 可重入 的。其可重入最大的作用是避免死锁,如:

synchronized 使用的时候,代码块和方法还是有点区别的。 当使用修饰的是方法的时候,只有一个标志位 ACC_SYNCHRONIZED。这个标志位其实就是是否需要获取锁对象monitor。而monitor 获取之后执行同步方法的步骤其实就和修饰代码块的流程开始一样了。

面试之敌系列 3 多线程详解

修饰的是代码块的时候,发编译后得到的内容如下:

面试之敌系列 3 多线程详解

监视器Monitor

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

1. MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
3. MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
复制代码
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }


复制代码
面试之敌系列 3 多线程详解

cxq,EntryList and WaitSet三者之间的区别

cxq(ContentionList),EntryList ,WaitSet。

  1. owner:指向持有锁的线程
  2. cxq(ContentionList):当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程插入到cxq队列的队首
  3. EntryList: 当持有锁的线程释放锁前,会将cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程
  4. WaitSet: 如果一个线程在同步块中调用了wait方法,会将该线程从EntryList移除并加入到WaitSet中,然后释放锁。当wait的线程被notify之后,会将对应的线程从WaitSet移动到EntryList中

注意,新来的竞争线程是加入到的cxq中的,而EntryList是每次唤醒的时候从cxq中搬运过来的线程。

整个唤醒和等待的流程

monitor 竞争

当多个线程执行到同步代码块时就会产生竞争,synchronized会执行monitorenter指令,最终会调用C++的ObjectMonitor::enter方法。 1.通过CAS尝试把monitor的owner字段设置为当前线程。 2.如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行recursions++,记录锁重入的次数。 3.如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获得锁并返回。 4.如果获取锁失效,则进入等待队列,等待锁的释放。

等待流程

等待流程概括小结 1.当前线程被封装成ObjectWaiter对象的node,状态设置为ObjectWaiter::TS_CXQ。 2.在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到cxq列表中。 3.node节点push到_cxq列表之后,通过自旋尝试获取锁,如果没有获取到锁,则通过park将当前线程挂起,等待被唤醒。 4.当线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock尝试获取锁。

锁的释放

recursions对应线程的重入次数,可重入锁,当减为0时说明线程完全出了同步代码块并释放锁,同时根据不同的策略(QMode指定)去唤醒正在等待阻塞的线程,从等待链表_cxq和_EntryList中获取头结点,通过ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由UNpark操作。

面试之敌系列 3 多线程详解
面试之敌系列 3 多线程详解
面试之敌系列 3 多线程详解
面试之敌系列 3 多线程详解

Synchronized和ReentrantLock的区别

原理弄清楚了,顺便总结了几点Synchronized和ReentrantLock的区别:

Synchronized是JVM层次的锁实现,ReentrantLock是JDK层次的锁实现; Synchronized的锁状态是无法在代码中直接判断的,但是ReentrantLock可以通过ReentrantLock#isLocked判断; Synchronized是非公平锁,ReentrantLock是可以是公平也可以是非公平的; Synchronized是不可以被中断的,而ReentrantLock#lockInterruptibly方法是可以被中断的; 在发生异常时Synchronized会自动释放锁(由javac编译时自动实现),而ReentrantLock需要开发者finally块中显示释放锁; ReentrantLock获取锁的形式有多种:如立即返回是否成功的tryLock(),以及等待指定时长的获取,更加灵活; Synchronized在特定的情况下对于已经在等待的线程是后来的线程先获得锁(上文有说),而ReentrantLock对于已经在等待的线程一定是先来的线程先获得锁;

公平和非公平

ReentrantLock

首先,非公平锁的性能比较好,但是可能会出现线程饥饿等情况。公平锁可以保证获取锁的顺序,但是需要付出额外的维护开销。

ReentrantLock 底层是实现了AQS,这个类里面实现了一些公用的方法,例如

面试之敌系列 3 多线程详解
面试之敌系列 3 多线程详解
面试之敌系列 3 多线程详解

公平锁和非公平锁都是实现了 Sync这个类。这个类主要有两个方法,一个是lock()函数用于获取锁,另外一个是尝试获取锁tryLock();

面试之敌系列 3 多线程详解

其中,在Sync中已经实现了非公平锁的nonfairTryAcquire()。实现ReentrantLock的时候,默认创建的是非公平锁。

面试之敌系列 3 多线程详解
  1. 从非公平锁的类中可以知道,lock()的时候,第一次尝试CAS获取锁,成功则设置锁标志为当前线程。如果不成工,就进入acquire()函数。
  2. 非公平锁中的nofairTryLock()是直接调用Sync中的函数。

注意,这个方法是会被acquire()函数调用的。

面试之敌系列 3 多线程详解

公平锁类的lock不会CAS去尝试获取锁。而是直接使用acquire()来获取锁。下面我们看看acquire()函数如何获取锁。acquire()函数是AQS中的一个函数,如下图:

面试之敌系列 3 多线程详解

这个函数里面主要有一个被公平和非公平锁重写了的tryAcquire(); 其中非公平锁会检测一下是否存在阻塞队列hasQueuedPredecessors(),这里是唯二区别。后面就是判断是否获取锁,是否为重入锁等。如果获取失败,听过acquire()函数可以知道,此时会执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg))函数。

面试之敌系列 3 多线程详解

从上面的AQS可以知道,首先会把线程包装成一个Node对象,然后加入一个阻塞队列的链表尾中。采用循环和CAS来保证入队成功和线程安全。 当入队成功之后,执行上图的此时会执行acquireQueued()函数,这里会先判断是否为队列的首位,如果成功了,那么就会直接CAS获取锁,否则,执行一个park函数。进行阻塞。

公平和非公平锁–ReentrantLock

乐观锁和悲观锁

乐观锁

为了不会阻塞的访问同步资源,引入了乐观锁机制。乐观锁机制其实就是一种简单的思想,具体实现是,表中有一个版本字段,第一次读的时候,获取到这个字段。处理完业务逻辑开始更新的时候,需要再次查看该字段的值是否和第一次的一样。如果一样更新,反之循环继续尝试。乐观锁的一种实现就是CAS + 版本号。
复制代码

悲观锁

总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加(悲观)锁。一旦加锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。频繁的阻塞和唤醒可能会带来很大的开销。相反的,乐观锁不会加锁。
复制代码

经典的生产者和消费者三种实现

PV 操作

PV操作:首先要保证P操作和V操作是原子性的。其次,要明确PV 操作的意义是对资源的访问控制,也就是基于资源S 的情况来执行响应的线程阻塞和唤醒操作。

对于生产者和消费者模式,也叫公共缓冲区的并发问题,我们一般使用两个变量来标志该缓存区剩余的空间以及被占用的空间。被占用的空间,称为 producer,表示已经存放了商品;buffer 表示缓存区剩余的空间。 互斥量为 S=1。表示的是只有一个资源。由于一开始我们的缓存区为12个,因此,我们设置S=12 。表示的是当该资源使用完之后,就会使得线程进行阻塞。具体的P V 操作如下:

面试之敌系列 3 多线程详解

V 操作表示对某个资源的释放,注意,资源释放之后才做判断,如果还有S<=0。有线程使用了资源,因此需要唤醒该线程。

面试之敌系列 3 多线程详解

具体的实现:

面试之敌系列 3 多线程详解

注意,虽然对通过资源的 PV 是需要成对的,但是可以在不同的函数中。

注意 S 的含义

首先,S 表示的是资源的可以使用情况,S=0表示资源已经被使用完,那么此时一定有线程占用了该资源。

资源申请

当一个线程申请完资源 此时申请 S : S-- ,如果此时S《0,那么就表示没有资源可以使用了,此时需要进行阻塞。如果S >=0,那么就表示申请资源是成功的。

资源的释放

当一个线程是否了该资源: 此时执行 S++ ,如果此时S<=0,那么就表示还有其他线程占用了资源还没有释放。由于每次只有一个线程可以持有该同步代码块的使用权(因为其他的线程被阻塞了)。因此,需要唤醒阻塞的线程。因为我就释放了一个资源,因此,只能唤醒一个线程。

总结:S==0就是临界的点。

ReentrantLock and Condition

这个其实就是PV 操作的官方的版本感觉。不再自己实现PV 操作,不再自己进行 资源使用情况和对应情况下的判断,操作等。

ReentrantLock 提供的可重入锁来实现锁的获取和释放

面试之敌系列 3 多线程详解

condition 的含义

对于Condition,JDK API中是这样解释的:

Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set(wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。

条件(也称为条件队列 或条件变量)为线程提供了一个含义,以便在某个状态条件现在可能为 true 的另一个线程通知它之前,一直挂起该线程(即让其“等待”)。因为访问此共享状态信息发生在不同的线程中,所以它必须受保护,因此要将某种形式的锁与该条件相关联。等待提供一个条件的主要属性是:以原子方式 释放相关的锁,并挂起当前线程,就像 Object.wait 做的那样。

Condition 实例实质上被绑定到一个锁上。要为特定 Lock 实例获得 Condition 实例,请使用其newCondition() 方法。

当我们创建完一个lock 之后,可以为这个lock 绑定多个阻塞队列。例如这里我们可以使用两个队列来,一个阻塞生产者,另外一个阻塞消费者。当产品满了的时候,生产线程挂到生产者阻塞队列中;当商品空了的时候,消费消除阻塞到消费者阻塞队列中。但生产完一件商品时候,唤醒的是一个消费阻塞队列中的线程。同样的,当消费一件商品之后,唤醒生产阻塞队列中的一个生产线程。注意这里和synchronize 不一样,synchronize 默认每次都会唤醒所有被阻塞的线程(不论是生产还是消费的线程,最大的原因还是只有一个阻塞队列。)

因此,从这一点来说,其实和 PV 操作是有点一一样的。

面试之敌系列 3 多线程详解

可以看到,一个lock ,但是注册了两个condition 也就是两个的阻塞队列。

synchronized 关键字

synchronize就是基本操作了。

并发集合工具类和线程安全集合

equals, ==, hashcode的区别

首先,equals()函数一般用于比较两个变量是否相等。这里的相等是逻辑是的相等。equals()一般会先试用==来确定这两个变量的地址是否一致,如果不一致,那么就进行内容的比较。因此,equals()就是一个全方面的比较方法 最简答的就是Object中的equals()直接返回的是==,两个变量的地址值。

== 主要用于比较变量的值的。如果是基本的数据类型,则直接比较值,如果是引用类型,则比较的是引用的地址值。对象是放在堆中的,引用是放栈中的。

hashcode 是一种映射编码函数。用于标志一个对象的编码。规定,相同的对象(逻辑上相等),其hashcode是一定相等的。不相等的对象其hashcode也可能相等。因此,hashcode也是用来比较对象是否相等的。它只是想布隆过滤器一样,只能判断不相等的hashcode一定不相等。

最常用的方法:

  1. 首先hashcode比较,如果不相等就不是相同的对象,反之进入第二部
  2. 如果hashcode 相等,也不一定是相等对象,此时在执行equals()来比较。

这样做的效率很高,因为不用每次一开始就使用equals()来进行比较。

但是,一般我们的equals 逻辑是自己写的,因此,我们还要自己确保equals的两个对象的hashcode是一样的。因此,重写了equals 一定要重写hashcode。

看似简单的hashCode和equals面试题,竟然有这么多坑!

HashMap HashTable ConcurrentHashMap

这里面的hashcode 仅仅是用来定位数组的。tables是一个Entry数组,其中的Entry是一个节点的数据格式。带有指针。

公共部分:

  1. 初始化桶,也就是Entry 数组的大小–table;采用默认的桶值16。
  2. 负载因子,默认为0.75.负载因子决定了进行扩容的阈值16×0.75=12。

1.7 的put 方法

if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果遍历之后没有找到,表明桶为空。
        modCount++;
        
        //addEntry实现了空桶和非空桶两种增加模式。扩容也是在addEntry中实现的。
        addEntry(hash, key, value, i); 
        return null;
    }
复制代码
  • 判断当前数组是否需要初始化。
  • 如果 key 为空,则 put 一个空值进去。
  • 根据 key 计算出 hashcode。
  • 根据计算出的 hashcode 定位出所在桶。
  • 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
  • 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。

1.8 的put方法

在1.7中 当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。 因此 1.8 中引入红黑树,重点优化了这个查询效率。

面试之敌系列 3 多线程详解

图中可知,使用了链表和红黑树的结构,但链表的长度大于8的时候,自动进行树化。反树化则是6

面试之敌系列 3 多线程详解

1.8中的put的详细解释

面试之敌系列 3 多线程详解
  1. 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。 最后判断是否需要进行扩容。

www.jianshu.com/p/c0642afe0…

www.javazhiyin.com/37729.html

blog.csdn.net/u010842515/…

juejin.im/post/5db943…

混淆的概念

一个对象锁被别人获取了,我还可以访问该对象进行其他的操作吗?

可以的,锁被别人获取了,但是我还是可以访问该对象的。而且可以执行所有的没有加锁的函数。当你要去执行加锁的代码块的时候,就会被阻塞。

所谓的对资源加锁,意思就是对某个变量加锁吗?

不是的。其实是对一个共享的资源的读写操作加锁。例如我们常说的读写,读读,写写等操作,针对的是一个共享的资源的修改。我们只是对这些修改的指令进行加锁。其实你可以只对其中的一个方面加锁,例如读的时候加锁,例如写的时候加锁,例如读写都加锁等。concurrentHashMap 中只对put 进行了加锁,此时其实是一个写锁。锁住的是一整个的链表或者树根。但是此时依旧可以访问这个对象中的所有的属性和没有加锁的代码块。注意是所有的变量。。。。即使是同步代码看重点关注的变量。。。。这一点很神奇。

volatile 关键字

当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。通过这两步可以实时的保证每个线程得到的数据都最新的。

详解四种单例模式

首先,了解一下类的加载时机:

  1. 加载一个类时,其内部类不会同时被加载。
  2. 一个类被加载,当且仅当其某个静态成员(静态域、构造器、静态方法等)被调用时发生。

也就是,即使外部类被使用到了,但是此时是不会同时加载内部类的,不管是不是静态的内部类。除非此时你显式的调用该类的一些静态的属性或者代码块。

饿汉式

public class Singleton {  
     //无论是否使用,都会先创建,可能会有性能的缺陷
     private static Singleton instance = new Singleton();  
     private Singleton (){
     }
     public static Singleton getInstance() {  
     return instance;  
     }  
 }  

复制代码

懒汉式

//线程不安全的模式
    public class Singleton {  
          private static Singleton instance;  
          private Singleton (){
          }   
          public static Singleton getInstance() {  
          if (instance == null) {  
          //由于线程不安全,可能会多次创建对象
              instance = new Singleton();  
          }  
          return instance;  
          }  
     }  
复制代码
public class Singleton {  
      //线程安全模式
      private static Singleton instance;  
      private Singleton (){
      }
      public static synchronized Singleton getInstance() {  
      if (instance == null) {  
          instance = new Singleton();  
      }  
      return instance;  
      }  
 }  

    
复制代码

双重检验锁

public class Singleton {  
      
      //volatile防止指令重排。
      private volatile static Singleton instance;  
      private Singleton (){
      }   
      public static Singleton getInstance() {  
      if (instance== null) {  
          synchronized (Singleton.class) {  
          if (instance== null) {  
              //这个赋值语句不是原子操作,包括了一个对象创建和赋值的过程。
              //这里其实有3个步骤,分配内存地址,初始化对象,将地址赋值给单例。
              //但是这里的顺序是不一定的,也就是可能出现先赋值再初始化的过程。
              //而在初始化的时候,如果有新的线程如果得到单例已经赋值,那么可能得到
              //一个空的单例。但使用了volatile时候,会保证执行的顺序为
              //单例的赋值一定是在最后的。(代码顺序性)
              instance= new Singleton();  
          }  
         }  
     }  
     return singleton;  
     }  
 }  

复制代码

静态内部类

public class Singleton {  
    private Singleton() {}  
    
    
    //静态内部类不会被初始化,除非触发初始化条件
    static class SingletonHolder {  
        private static final Singleton instance = new Singleton();  
    } 
      
    public static Singleton getInstance() {  
        //触发初始化条件。调用了静态内部类的一个静态的属性。
        //之所以要是静态的内部类,是因为内部类不能有静态属性,因此要静态内部类。
        //简单就是静态内部类才能有静态的属性。
        return SingletonHolder.instance;
    }  
}  
复制代码

注:本系列博文主要是做的信息整合方面的工作,很感谢收集到的各位的博文,很多地方还没有注明出处,主要是在看的时候有想加入自己的一些想法等。因此,对于参考到的,没有标出reference 的博文 ,我后续会标注上,并对此表示真诚地道歉!!

原文 

https://juejin.im/post/5f0d70936fb9a07e8646f702

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 面试之敌系列 3 多线程详解

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址