Java多线程学习-2

目录

ThreadLocal原理与实践

ThreadLocal的基本使用

 ThreadLocal使用场景

 ThreadLoacl源码分析

Java内置锁原理

synchronized关键字

volatile关键字

 原理分析

Java对象结构

加锁和释放锁的原理

JVM锁优化

自旋锁

自适应自旋锁

锁消除

锁粗化

轻量级锁

偏向锁

重量级锁

重量级锁核心原理

重量级锁数据结构

重量级锁的开销

并发三要素

线程间通信


多线程以及并发的知识,属于Java中进阶的知识,是八股文中比较难啃的部分。但是理解了之后,对应日常工作的影响很深远,因此通过脑图,各种流程图力求能把这部分知识说透。

之前的文章

Java多线程学-1https://blog.csdn.net/u012886468/article/details/122343061?spm=1001.2014.3001.5501

中把多线程基础知识介绍了。接下来是进阶的知识点,一起来学习吧~

ThreadLocal原理与实践

有些场景,为了保证多个线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中。这样做会使得每个线程都有独立的副本,不会出现一个线程读取另一个线程修改的现象。

那原理是怎么样的呢?哈哈,一步一步来,先了解下基本使用。

ThreadLocal的基本使用

ThreadLocal位于JDK的java.lang核心包中。

如果程序创建了一个ThreadLocal实例,那么在访问这个变量的值时,每个线程都会拥有一个独立的、自己的本地值。即会为每个线程中对该变量会创建一个副本。当线程结束后,每个线程所拥有的那个本地值会被释放。

jdk7以前:

一个ThreadLocal实例可以形象地理解为一个Map

其中Key为线程Thread实例,Value为待保存的值

jdk8中:

Map中key由 Thread实例,变成  ThreadLocal实例,value不变。

且 这个Map变成了 Thread类的成员变量,即每一个Thread线程内部都有一个Map(ThreadLocalMap),如果给一个Thread创建多个ThreadLocal实例,然后放置本地数据,那么当前线程的ThreadLocalMap中就会有多个“Key-Value对”,其中ThreadLocal实例为Key,本地数据为Value。

从代码的层面来说,新版本的ThreadLocalMap还是由ThreadLocal类维护的,由ThreadLocal负责ThreadLocalMap实例的获取和创建,并从中设置本地值、获取本地值。所以ThreadLocalMap还寄存于ThreadLocal内部,并没有被迁移到Thread内部。

如图所示:

 

 ThreadLocal使用场景

线程隔离和跨函数传递数据。

 ThreadLoacl源码分析

主要有:set(T value)方法、get()方法、remove()方法和initialValue()方法。

    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);
    }

ThreadLocalMap主要的属性和方法

看set()方法的源码:

        private void set(ThreadLocal<?> key, Object value) {

            // We don't use a fast path as with get() because it is at
            // least as common to use set() to create new entries as
            // it is to replace existing ones, in which case, a fast
            // path would fail more often than not.

            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);

            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();

                if (k == key) {
                    e.value = value;
                    return;
                }

                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

int i = key.threadLocalHashCode & (len-1);

 这句代码的含义是  key.threadLocalHashCode %  len,取余,获取当前的ThreadLocal在数组的下标是多少,比如i=3

然后通过i=3的位置上元素的key与threadLocal的key对比,如果相同就替换为新的;

如果不相同,就调用 replaceStaleEntry。

接着进行清理ThreadLocal被回收的条目(因为WeakReference是弱引用,GC的时候会被清除)并且数组中的数据大小>阈值的时候对着当前的Table进行重新哈希。

了解了Set的源码,接下来看Get的源码

        private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
        }

先找到ThreadLocal的索引位置, 如果索引位置处的entry不为空并且键与threadLocal是同一个对象, 则直接返回; 否则去后面的索引位置继续查找。

编程规范推荐使用static final修饰ThreadLocal对象。不过这样做会带来副作用,使得Thread实例内部的ThreadLocalMap中Entry的Key在Thread实例的生命期内将始终保持为非null,从而导致Key所在的Entry不会被自动清空有时候,为了避免内存泄漏,需要提供清除的方法,来看remove方法源码:

        private void remove(ThreadLocal<?> key) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                if (e.get() == key) {
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

找到Key对应的Entry, 并且清除Entry的Key(ThreadLocal)置空, 随后清除过期的Entry即可避免内存泄露

Java内置锁原理

Java中内置锁(对象锁,隐式锁)是一个互斥锁,且Java中每个对象都可以用作锁,这些锁就是成为内置锁。

线程进入同步代码块或者方法时自动获得该锁,退出会释放锁。

synchronized关键字

volatile关键字

 final关键字

 原理分析

Java对象结构

Java对象(Object实例)结构包括三部分:对象头,对象体和对齐字段

 通过对象头来体现,当前对象锁处于什么状态:无锁,偏向锁,轻量级锁,重量级锁。

加锁和释放锁的原理

写一些测试代码

public class SynchronizedTest {

    private Integer a;

    private Integer b;

    private synchronized void test(){
        
    }


    private void test2(){
        synchronized (this){
            
        }
    }
}

通过  idea jclasslib工具,可以看到

其中,我们比较关注的是 test 方法 和 test2方法,因为前者加了 synchronized关键字,后者加了synchronized同步代码块,我们看看字节码反映出来的信息。

 来看第一种情况:同步方法

 即 class文件中,methods结构体中access_flags会标志着该方法是否是同步的。

同步方法是 通过 Java对象结构-对象头-Mark World-锁状态字段来判断的。

可以通过jol工具来查看对象头布局。

第二种情况:方法块

monitorenter和monitorexit指令,会让对象在执行,使其锁计数器加1或者减1。

 而通过锁的计数器,可以实现锁的可重入特性。

JVM锁优化

JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。

不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

本质来说,就是尽量减少调用系统硬件锁等硬件层面的,通过软件层面来解决问题。

  • 像 锁粗化,就是把更多的锁合并成数量更少但范围更大的锁。也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除就是 把没用的锁给去掉
  • 偏向锁,是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 轻量级锁:这种锁依托一种假设,即在真实情况下程序中其实大部分同步代码都是处于无锁状态的,在无锁状态下应该避免调用系统层面的重量级锁,取而代之就是调用CAS原子指令就可以完成锁的获取和释放。当存在锁竞争的情况下,执行CAS失败的线程将调用系统互斥锁进入阻塞状态,当锁被释放的时候被唤醒。
  • 适应性自旋:当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入重量级锁前会进入忙等待然后再次尝试,当尝试一次的次数后仍然没有成功则调用与该monitor相关的锁进入阻塞状态。

自旋锁

引入原因:系统层面的mutex Lock比较耗资源,调用同步 会让所有竞争的线程都处于阻塞状态,对性能影响较大。同时发现锁定状态持续的时间很短,因此可以让没有获得锁的线程等待一会(自旋),到时候再竞争锁。

在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

自适应自旋锁

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

  • 如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间
  • 相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源

锁消除

虚拟机即时编译器运行的时候,发现一些数据不会逃逸但还是使用了同步锁,则会把锁消除。

锁粗化

如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,就会锁粗化,提高性能。

轻量级锁

对在大多数情况下同步块并不会有竞争出现提出的一种优化,可以减少重量级锁对线程的阻塞带来地线程开销,从而提高并发性能。

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)。

过程

如果当前对象没有被锁定,那么锁标志位位01状态

执行时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝。锁记录有两部分组成,分别是 Displaced hdr和Owner。

然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中(方法区的栈帧中),并且将Mark Word中的ptr_to_lock_record(锁记录指针)更新为指向Lock Record的指针。

 如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态。且JVM会将Mark Word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word(原来Displaced hdr);再将抢锁线程中锁记录的owner指针指向锁对象(即右边对象);

如果这个更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针(即Mark Word中的ptr_to_lock_record字段),如果有,说明该锁已经被获取,可以直接调用。

如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞。

锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的Mark Word的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。

总结:尽量减少调用操作系统的锁操作(膨胀为重量级锁),通过CAS来避免。如果实在避免不了,就调用系统锁(膨胀为重量级锁)。

偏向锁

轻量级锁我们知道了操作前我们会在栈帧中创建锁记录,并且通过CAS来原子替换Mark Word相关数据,这个是基础知识。在jdk1.6的进行了优化,当一个线程访问同步快并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

偏向锁的撤销步骤:

  1. 在一个安全点停止拥有锁的线程
  2. 遍历线程的栈帧,检查是否存在锁记录。如果存在锁记录,就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID
  3. 将当前锁升级成轻量级锁
  4. 唤醒当前线程

撤销偏向锁的条件:

  1. 多个线程竞争偏向锁
  2. 调用偏向锁对象的hashcode()方法或者System.identityHashCode()方法计算对象的HashCode之后,将哈希码放置到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销。

偏向锁的膨胀:

  1. 如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了,JVM检查原来持有该对象锁的占有线程是否依然存活
  2. 如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。
  3. 如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调用堆栈是否通过锁记录持有偏向锁
  4. 如果存在锁记录,就表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁

缺点:如果锁对象时常被多个线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销。

小结一下:轻量锁是通过调用CAS来避免非必要的情况膨胀为重量级锁,而偏向锁是通过简单测试一下对象头的Mark Word字段是否存储着指向当前线程的偏向锁 来避免多调用CAS(因为调用CAS也是很大消耗的)。

重量级锁

在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。

监视器相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。

重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。

重量级锁核心原理

JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。监视器的义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。

重量级锁数据结构

在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,定义在ObjectMonitor.hpp文件中

ObjectMonitor重要属性:

重量级锁的开销

线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。

有以下特点: 

  • Linux系统的体系架构分为用户态(或者用户空间)和内核态(或者内核空间);
  • 用户态与内核态有各自专用的内存空间、专用的寄存器等;
  • 进程从用户态切换至内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作
  • 用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”
  • 一个进程可以运行在用户态,也可以运行在内核态,那么肯定存在用户态和内核态切换的过程

用户态到内核态切换主要包括方式:

  • 硬件中断。硬件中断也称为外设中断,当外设完成用户的请求时会向CPU发送中断信号。
  • 系统调用。其实系统调用本身就是中断,只不过是软件中断,跟硬件中断不同。
  • 异常。如果当前进程运行在用户态,这个时候发生了异常事件(例如缺页异常),就会触发切换。

由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。

并发三要素

原子性,可见性,有序性 统称 并发三要素。

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。问题的根本是:分时复用导致有可能操作不存在原子性。

可见性:个线程对共享变量的修改,另外一个线程能够立刻看到。问题的根本是:CPU缓存引起的。

有序性:即程序执行的顺序按照代码的先后顺序执行。问题的根本是:重排序引起的。

小结:
synchronized: 具有原子性,有序性和可见性。

volatile:具有有序性和可见性。

线程间通信

如果需要多个线程按照指定的规则共同完成一个任务,那么这些线程之间就需要互相协调,这个过程被称为线程的通信

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

DonaldCen

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

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

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

打赏作者

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

抵扣说明:

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

余额充值