并发中的Synchronized、Lock、Volite、Map、ThreadLocal

内容整理自网络,具体参考参考文章

Synchronized

Java SE 1.6 对 synchronized 进行了各种优化,为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

偏向锁:每次都是你来,直接给你钥匙

轻量级锁:在门口徘徊尝试获取钥匙

重量级锁:回去等待钥匙

synchronized 的三种应用方式

synchronized 有三种方式来加锁,分别是:方法锁,对象锁 synchronized(this),类锁 synchronized(Demo.Class)其中在方法锁层面可以有如下 3 种方式:

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized 括号后面的对象

synchronized 扩号后面的对象是一把锁,在 java 中任意一个对象都可以成为锁,简单来说,我们把 object 比喻是一个 key,拥有这个 key 的线程才能执行这个方法,拿到这个 key 以后在执行方法过程中,这个 key 是随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有 key 所以不能访问只能在门口等着,等之前的线程把 key 放回去。所以,synchronized 锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。

synchronized 的锁的原理

jdk1.6 以后对 synchronized 锁进行了优化,包含偏向锁、轻量级锁、重量级锁;了解 synchronized 的原理我们需要明白 3 个问题:

1.synchronized 是如何实现锁

2.为什么任何一个对象都可以成为锁

3.锁存在哪个地方?一个是对象头、另一个是 monitor。

Java 对象头

在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键

Mawrk Word:

Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit),下面就是对象头的一些信息:

image.png

Monitor:

什么是 Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制。所有的 Java 对象是天生的 Monitor,每个 object 的对象里 markOop->monitor() 里可以保存 ObjectMonitor 的对象。

synchronized 是如何实现锁

了解了对象头以及 monitor 以后,接下来去分析 synchronized 的锁的实现,就会相对简单了。前面讲过 synchronized 的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁-> 偏向锁-> 轻量级锁-> 重量级锁.锁的类型:锁从宏观上分类,分为悲观锁与乐观锁。

乐观锁:

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

悲观锁:

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java 中的悲观锁就是 Synchronized,AQS 框架下的锁则是先尝试 cas 乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。

自旋锁(CAS):

自旋锁就是让不满足条件的线程等待一段时间,而不是立即挂起。看持有锁的线程是否能够很快释放锁。怎么自旋呢?其实就是一段没有任何意义的循环。虽然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程不能在很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何有意义的工作。所以,自旋等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。JDK1.6 中-XX:+UseSpinning 开启; -XX:PreBlockSpin=10 为自旋次数; JDK1.7 后,去掉此参数,由 jvm 控制;

偏向锁:

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。下图就是偏向锁的获得跟撤销流程图:

image.png

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 01(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。执行同步块。这个时候线程 2 也来访问同步块,也是会检查对象头的 Mark Word 里是否存储着当前线程 2 的偏向锁,发现不是,那么他会进入 CAS 替换,但是此时会替换失败,因为此时线程 1 已经替换了。替换失败则会进入撤销偏向锁,首先会去暂停拥有了偏向锁的线程 1,进入无锁状态(01).偏向锁存在竞争的情况下就回去升级成轻量级锁。

偏向锁的获取
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了
偏向锁的撤销
当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。并且直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
1.原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
2.原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块

在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。

轻量级锁:

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,下面是轻量级锁的流程图:

image.png

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。这个时候 JVM 会尝试使用 CAS 将 mark Word 更新为指向栈帧中的锁记录(Lock Record)的空间指针。并且把锁标志位设置为 00(轻量级锁标志),与此同时如果有另外一个线程 2 也来进行 CAS 修改 Mark Word,那么将会失败,因为线程 1 已经获取到该锁,然后线程 2 将会进行 CAS 操作不断的去尝试获取锁,这个时候将会引起锁膨胀,就会升级为重量级锁,设置标志位为 10.

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

自旋锁
轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。
在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

重量级锁:

重量级锁通过对象内部的监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行 阻塞 ,被阻塞的线程不会消耗 cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到 内核态 ,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么重量级线程开销很大的。

各种锁的比较

image.png

Lock

CAS+AQS 队列来实现

(1):先通过 CAS 尝试获取锁, 如果此时已经有线程占据了锁,那就加入 AQS 队列并且被挂起;

(2):当锁被释放之后, 排在队首的线程会被唤醒 CAS 再次尝试获取锁,

(3):如果是非公平锁, 同时还有另一个线程进来尝试获取可能会让这个线程抢到锁;

(4):如果是公平锁, 会排到队尾,由队首的线程获取到锁。

AQS 原理

Node 内部类构成的一个双向链表结构的同步队列,通过控制(volatile 的 int 类型)state 状态来判断锁的状态,对于非可重入锁状态不是 0 则去阻塞;

对于可重入锁如果是 0 则执行,非 0 则判断当前线程是否是获取到这个锁的线程,是的话把 state 状态 +1,比如重入 5 次,那么 state=5。而在释放锁的时候,同样需要释放 5 次直到 state=0 其他线程才有资格获得锁

AQS 两种资源共享方式

  • Exclusive:独占,只有一个线程能执行,如 ReentrantLock
  • Share:共享,多个线程可以同时执行,如 Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

Volite

volatile 关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

volatile 的原理和实现机制

volatile 到底如何保证可见性和禁止指令重排序的。

下面这段话摘自《深入理解 Java 虚拟机》:

“观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现, 加入 volatile 关键字时,会多出一个 lock 前缀指令

lock 前缀指令实际上相当于一个 内存屏障 (也成内存栅栏),内存屏障会提供 3 个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

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

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

ThreadLocal

ThreadLocal 简介

ThreadLocal(是 Thread Local Variable,线程局部变量)类是 Java 为线程安全提供的一个工具类,代表一个线程局部变量。把数据放在 ThreadLocal 中可以让每个线程创建一个该变量的副本,线程间可以独立地改变自己的副本,而不会和其他线程产生副本冲突,从而避免并发访问的线程安全问题,就像每个线程都完全拥有该变量一样。

ThreadLocal 与 Synchronized 区别

ThreadLocal 其实是与线程绑定的一个变量。ThreadLocal 和 Synchonized 都用于解决多线程并发访问。
但是 ThreadLocal 与 synchronized 有本质的区别:
1、Synchronized 用于线程间的数据共享,而 ThreadLocal 则用于线程间的数据隔离。
2、Synchronized 是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。能够数据共享。
3、ThreadLocal 为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,隔离数据。

image.png

ThreadLocal 简单使用
T get() //返回此线程局部变量的当前线程副本值
void remove() //删除此线程局部变量的当前线程的副本值
void set(T value) //设置此线程局部变量中当前线程副本值
package com.concurrent.demo1;

import java.util.concurrent.TimeUnit;

/**
 * @author lane
 * @date 2021年11月18日 下午7:43
 */
public class ThreadLocalDemo {

    public static void main(String[] args) {

        ThreadLocal<String> threadLocal = new ThreadLocal<>();

        new Thread(()->{
            String name= "zhangsan";
            threadLocal.set(name);

        },"A线程").start();

        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException interruptedException) {
                interruptedException.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()+"before获取threadlocal的数据为:"+threadLocal.get());
            String name= "lisi";
            threadLocal.set(name);
            System.out.println(Thread.currentThread().getName()+"after获取threadlocal的数据为:"+threadLocal.get());
            threadLocal.remove();
        },"B线程").start();

    }
}
//B线程before获取threadlocal的数据为:null
//B线程after获取threadlocal的数据为:lisi

ThreadLocal 原理

ThreadLocal 类下面有个静态内部类 ThreadLocalMap

ThreadLocalMap 内部有一个静态内部类 Entry

Entry 的 key 为 ThreadLocal value 为 set 的值

而在 Thread 下面有一个属性 ThreadLocal.ThreadLocalMap threadLocals = null;

image.png

  public class ThreadLocal<T> {

	static class ThreadLocalMap {

		static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
} 

ThreadLocal.ThreadLocalMap threadLocals = null;

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

   
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }


public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null) {
             m.remove(this);
         }
     }

Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,也就是说每个线程有一个自己的 ThreadLocalMap。

ThreadLocalMap 有自己的独立实现,可以简单地将它的 key 视作 ThreadLocal,value 为代码中放入的值(实际上 key 并不是 ThreadLocal 本身,而是它的一个弱引用)。

每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。

ThreadLocalMap 有点类似 HashMap 的结构,只是 HashMap 是由数组 + 链表实现的,而 ThreadLocalMap 中并没有链表结构。

ThreadLocal 内存泄露

​内存泄露

内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
广义并通俗的说,就是:不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。

强引用与弱引用

强引用,使用最普遍的引用,一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不回收这种对象。

如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为 null,这样可以使 JVM 在合适的时间就会回收该对象。

弱引用,JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在 java 中,用 java.lang.ref.WeakReference 类来表示。可以在缓存中使用弱引用。
ThreadLocal 内存泄露

ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 不存在外部强引用时,Key(ThreadLocal)势必会被 GC 回收,这样就会导致 ThreadLocalMap 中 key 为 null, 而 value 还存在着强引用,只有 thread 线程退出以后,value 的强引用链条才会断掉。

但如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永远无法回收,造成内存泄漏。

为什么使用弱引用而不是强引用

key 使用强引用
当 threadLocalMap 的 key 为强引用回收 ThreadLocal 时,因为 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,导致 Entry 内存泄漏。

key 使用弱引用
当 ThreadLocalMap 的 key 为弱引用回收 ThreadLocal 时,由于 ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。当 key 为 null,在下一次 ThreadLocalMap 调用 set(),get(),remove()方法的时候会被清除 value 值。

ThreadLocal 内存泄漏的根源

由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏,而不是因为弱引用。

ThreadLocal 正确使用

每次使用完 ThreadLocal 都调用它的 remove()方法清除数据
将 ThreadLocal 变量定义成 private static,这样就一直存在 ThreadLocal 的强引用,也就能保证任何时候都能通过 ThreadLocal 的弱引用访问到 Entry 的 value 值,进而清除掉。

ThreadLocal 应用场景

场景 1:
ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
场景 2:
ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。

ConCurrentHashMap

ConcurrentHashMap 1.7

1. 存储结构

image.png

Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。

ConcurrentHashMap 1.8

存储结构

image.png

可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表 ,而是 Node 数组 + 链表 / 红黑树 。当冲突链表达到一定长度时,链表会转换成红黑树。

取消 segments 字段,直接采用 transient volatile HashEntry<K,V>[] table 保存数据,采用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,并发控制使用 Synchronized 和 CAS 来操作
将原先 table 数组 + 单向链表的数据结构,变更为 table 数组 + 单向链表 + 红黑树的结构.

初始化

ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。

Put

根据 key 计算出 hashcode 。

判断是否需要进行初始化。

即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。

如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。

如果都不满足,则利用 synchronized 锁写入数据。

如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。


参考文章

深入分析 Synchronized 原理(阿里面试题)

synchronized 原理

线程关键字(synchronized、volatile、偏向锁、自旋锁)

ThreadLocal 详解

ThreadLocal 详解(内存泄漏)

Java 复习之 ReentrantLock 原理(CAS+AQS)

ConcurrentHashMap 实现原理

ConcurrentHashMap 实现原理及源码分析

  • 3
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值