美文网首页java学习之路
JavaGuide知识点整理——并发进阶知识点(上)

JavaGuide知识点整理——并发进阶知识点(上)

作者: 唯有努力不欺人丶 | 来源:发表于2022-07-23 00:47 被阅读0次

    synchronized关键字

    说一说对synchronized关键字的了解

    synchronized关键字解决的是多线程之间访问资源的同步性。synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
    另外在java早期版本中,synchronized属于重量级锁,效率低下。
    因为监视器锁是依赖于底层的操作系统的Mutex Lock来实现的,java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个状态之间的转换都需要相对比较长的时间,时间成本相对较高。
    庆幸的是在java6之后java官方对jvm层面对synchronized较大优化,所以现在synchronized锁效率优化的也不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁等技术来减少锁操作的开销。
    所以你会发现目前的话,不论是各种开原框架还是JDK源码都大量使用了synchronized关键字。

    说说是怎么使用synchronized关键字的

    synchronized关键字最主要的三种使用方式:

    1. 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
    2. 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。因为静态成员不属于任何一个实例对象,是类成员(static修饰的是静态资源,不管new了多少个对象,只有一份)。所以,如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属的类的静态synchronized方法,是允许的,不会发生互斥现象。因为访问静态synchronized方法占用的锁是类的锁,而访问非静态synchronized方法占用的锁是当前实例对象的锁。
    3. 修饰代码块:指定加锁对象,对给定对象/类加锁。synchronized(this|object)表示进入同步代码块前要获得给定对象的锁。synchronized(类.class)表示进入同步代码块前要获得当前class的锁。

    总结:

    • synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁。
    • synchronized关键字加到实例方法上是给对象实例上锁。
    • 尽量不要用synchronized(String str),因为JVM中,字符串常量池具有缓存功能。

    下面说一个常见的 双重检测锁机制实现对象单例(线程安全)

    public class Singleton {
    
        private volatile static Singleton uniqueInstance;
    
        private Singleton() {
        }
    
        public  static Singleton getUniqueInstance() {
           //先判断对象是否已经实例过,没有实例化过才进入加锁代码
            if (uniqueInstance == null) {
                //类对象加锁
                synchronized (Singleton.class) {
                    if (uniqueInstance == null) {
                        uniqueInstance = new Singleton();
                    }
                }
            }
            return uniqueInstance;
        }
    }
    

    需要注意的是上面的uniqueInstance 需要采用volatile关键字修饰。
    这里需要的不是volatile的可见性,而是防止指令重排。
    本质上new XXX()是分为三步的

    1. 为实例对象分配内存空间
    2. 初始化实例对象
    3. 将对象指向分配的内存地址

    但是由于jvm可能指令重排,也就是1,2,3可能实际上是1,3,2执行的。指令重排单线程下不会出问题,但是多线程的话可能在1,3执行完有线程调用这个对象,也就是实际上虽然对象还没初始化,但是因为不为空但是被调用了。就会出问题。

    构造方法可以使用synchronized关键字修饰么?

    构造方法不能使用synchronized关键字修饰。
    因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。

    讲一下synchronized关键字的底层原理

    synchronized关键字底层原理属于JVM层面。
    synchronized修饰同步语法块的实现主要是实用monitorenter和monitorexit指令。其中monitorenter指令指向同步代码块开始的位置,monitorexit指令则指明同步代码块结束的位置。
    当执行monitorenter指令时,线程试图获取锁也就是获取对象监视器monitor的持有权。

    在java虚拟机(HotSpot)中,monitor是基于C++实现的,由 ObjectMonitor实现,每个对象中都内置了一个ObjectMonitor对象。
    另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才可以调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

    在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为0则表示锁可以被获取,获取后锁的计数器设为1.也就是加1。


    获取锁

    对象锁的拥有者线程才可以执行monitorexit指令来释放锁。在执行monitorexit指令后,将锁计数器设置为0,表示锁释放,其他线程可以尝试获取该锁了。


    释放锁
    如果获取对象锁失败,那么当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

    synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识、该标识指明了该方法是一个同步方法,JVM通过该访问标识来辨别一个方法是否是同步方法,从而执行相应的同步调用。
    如果是实例方法,JVM会尝试获取实例对象的锁,如果是静态方法,JVM会尝试获取当前class的锁。

    不过两者的本质都是对对象监视器monitor的获取。

    JDK1.6之后的synchronized关键字底层做了哪些优化?

    jdk1.6对锁的实现引入了大量的优化,如偏向锁,轻量级锁,自旋锁,适应性自旋锁,锁消除,锁粗化等技术来减少锁操作的开销。
    锁主要存在四种状态,依次是无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可以降级。这种策略是为了提高获得锁和释放锁的效率。
    大概简述一下锁升级
    首先无锁状态就不说了,我们说下偏向锁:
    偏向锁是针对于一个线程而言的,线程获取锁后就不会再有解锁等操作了。这样可以省去很多开销。假如有两个线程来竞争该锁的话,那么偏向锁就失效了。进而升级成轻量级锁了。
    然后具体的实现分为偏向锁获取锁:

    1. 偏向锁标志是未偏向状态,使用CAS将MarkWord 中的线程id设置为自己的线程id

      • 如果成功,获取偏向锁成功
      • 如果失败,进行锁升级
    2. 偏向锁标志是已偏向状态

      • MarkWord 中线程id是自己的线程id,获取锁成功
      • MarkWord 中线程id不是自己的线程id,进行锁升级

    偏向锁升级需要进行偏向锁撤销(前提是要是偏向锁)

    1. MarkWord 中指向的线程不存活
      • 允许重新偏向
      • 不允许重偏向,变为无锁状态
    2. MarkWord 中线程存活
      • 线程id指向的线程仍拥有锁,则升级为轻量级锁
      • 线程id指向的线程不拥有锁,允许偏向则退回到可偏向但未偏向的状态,不允许偏向就变为无锁状态。

    这个偏向锁可以用生活中的一个例子来理解:假如有个图书馆可以借书。第一个方案是随便谁都可以拿,只要拿的时候留个姓名就行了。这种情况也不需要管理员操心,很省时间。假设书还在图书馆,这个时候我们可以理解这个书是可偏向但未偏向的状态。

    然后如果某人觉得这本书不错,留个姓名,把书拿走了。 我们可以认为这个人获取偏向锁成功了(这里有个状态,就是这个人有点毛病,整天把这本书带来图书馆看,但是书其实还是在这个人名下的,所以不需要再次去借可以直接看书)。

    重点是又来了一个人觉得这本书不错,想要借这本书。此时是有两种可能的:

    1. 书在图书馆。
    2. 书在第一个人家。
      如果是第一种可能的话,就说明之前那个人看完了还回来了,如果书允许反复借的话(允许重偏向),那么其实第二个人是可以借到这本书的。也就是第二个人获取到了偏向锁。如果这本书不允许反复借的话,那么这个事就得升级处理了,因为管理员看这本书太抢手了,放养不行,升级成轻量级锁。
      现在说第二种可能,就是当第二个人来借这本书的时候,这本书在第一个人家里。遇到这种情况管理员就不能再图省事不管了,他得等第一个人还书的时候再通知第二个人来借。我们可以认为第二个人变成了等通知的状态,随时等待书回来。这也就是锁升级成轻量级锁,第二个人在自旋请求获得锁。

    然后轻量级锁膨胀成重量级锁就比较好理解了。简单一句话:轻量级锁是线程自旋等待获取锁,当自选次数达到界限值就会膨胀成重量级锁。就是等的时间太久,一直在那自旋卡着cpu不是那么回事,所以用重量级锁使用操作系统互斥量来实现的传统锁。
    重量级锁线程不自旋了,直接堵塞状态,不消耗cpu。
    需要注意的是锁膨胀成重量级锁后,就不会退回到轻量级了。

    synchronized和ReentrantLock的区别

    • 两者都是可重入锁
      可重入锁值的是自己可以再次获取自己的内部锁。同一个线程获取了某个对象的锁,此时锁还没释放的话,可以再次获取到这个对象的锁。如果是不可重入的锁就会造成死锁。
      同一个线程每次获取锁,锁的计数器上都加一,所以要等锁的计数器下降为0的时候才能释放锁。

    • synchronized依赖于JVM,而ReentrantLock依赖于API
      synchronized是依赖于JVM实现的,上面也讲了原理。甚至1.6的那么多优化都是在虚拟机层面实现的,并没有直接暴露给我们。而ReentrantLock是JDK层面实现的,所以我们可以查看它的源码,看它是如何实现的。

    • ReentrantLock比synchronized增加了一些高级功能
      相比于synchronized,ReentrantLock有一些高级功能,主要有三点:

      • 等待可中断:ReentrantLock提供了一种可以中断等待锁的线程的机制。也就是说正在等待的线程可以选择放弃等待。通过lock.lockInterruptibly()来实现。
      • 可实现公平锁:ReentrantLock可以指定公平锁还是非公平锁,而synchronized只能是非公平锁。
      • 可以实现选择性通知(锁可以绑定多个条件):synchronized关键字可以和wait/notify/notifyAll等方法结合实现等待通知机制。但是ReentrantLock可以借助Condition接口和newCondition()方法实现指定通知。

    如果想实现上述三点,那么选择ReentrantLock是一个不错的选择, 性能不是选择标准。

    volatile关键字

    CPU缓存模型

    为什么要弄一个CPU高速缓存呢?
    类比我们开发项目时用Redis缓存解决程序处理速度和访问常规关系型数据库速度不对等的关系。cpu缓存是为了解决CPU处理速度和内存处理速度不对等的问题。
    我们可以把内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
    总结:CPU Cache缓存的是内存数据用于解决CPU处理速度和内存不匹配的问题。内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

    CPU Cache工作示意图

    CPU Cache的工作方式:
    先复制一份数据到CPU Cache中,当CPU需要用到的时候可以直接从CPU Cache中读取数据,当运算完成后,再将运算得到的数据写回Main Memory中。但是这样会存在内存缓存不一致性问题。比如我执行一个i++操作,同事两个线程执行可能会得到2,正确结果应该是3.
    CPU为了解决内存缓存不一致性问题可以通过定制缓存一致协议或者其他手段来解决。

    JMM(Java内存模型)

    java内存模型抽象了线程和主内存之间的关系。就比如说线程之间的共享变量必须存储在主内存中。Java内存模型主要目的是为了屏蔽系统和硬件的差异。避免一套代码在不同的平台下产生的效果不一致。

    JDK1.2之前,java的内存模型实现总是从主内存读取变量,是不需要特别注意的。
    但是在当前的java内存模型下,线程可以把变量保存在本地内存中,而不是直接在主内存中读写。这样可以造成一个线程在主内存中修改了一个变量的值,而另一个线程还在用它在寄存器中的变量值的拷贝,造成数据的不一致。

    内存模型

    要解决这个不一致问题,就需要把变量声明为volatile,这就指示JVM,这个变量是共享且不稳定的,每次使用它都要到主内存中进行读取。
    所以volatile关键字除了防止指令重排,还有一个重要的作用就是保证变量的可见性。

    并发编程的三个重要特征

    1. 原子性:一次操作或者多次操作,要么所有的操作全部都执行并且不受任何因素的干扰而中断。要么都不执行。synchronized可以保证代码片段的原子性。
    2. 可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile关键字可以保证共享变量的可见性。
    3. 有序性:代码在执行的过程中的先后顺序。因为java在编译期以及运行期间的优化。代码的执行顺序未必是编写代码时候的顺序。volatile关键字可以禁止指令重排优化。

    synchronized关键字和volatile关键字的区别

    synchronized关键字和volatile关键字是两个互补的存在,而不是对立。

    • volatile关键字是线程同步的轻量级实现,所以volatile性能比synchronized关键字要好。但是volatile关键字只能修饰变量,而synchronized可以修饰方法和代码块
    • volatile关键字可以保证数据的可见性,但是不能保证数据的原子性。synchronized关键字两者都可以保证。
    • volatile关键字主要用于解决变量在多线程之间的可见性。而synchronized关键字解决的是多个线程之间访问资源的同步性。

    ThreadLocal

    ThreadLocal简介

    通常情况下,我们创建的变量是可以被任何一个线程访问并且修改的。如果想要实现每一个线程都有自己的专属本地变量该如何解决呢?JDK中提供了ThreadLocal类正是为了解决这样的问题。ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

    如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用get()和set()方法来获取默认值或者将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
    下面是ThreadLocal使用的demo:

    public class Test {
    
    
        public static final ThreadLocal<String> str = ThreadLocal.withInitial(()->"初始数据!");
    
        public static void main(String[] args) throws Exception {
    
            for(int i  = 0;i<5;i++){
                Thread thread = new Thread(()->{
                    System.out.println(DateUtils.format(new Date(),"yyyy-MM-dd HH:mm:ss") +str.get());
                    str.set(DateUtils.format(new Date(),"yyyy-MM-dd HH:mm:ss")+"被线程"+Thread.currentThread().getName()+
                        "修改了!");
                    System.out.println(str.get());
                });
                Thread.sleep(1000l);
                thread.start();
            }
        }
    }
    
    运行结果

    从运行结果可以看出来,前面的线程明明都已经改变了字符串的值,但是后面获取的还是初始值。

    ThreadLocal原理

    这个要从Thread类源码入手:


    Thread源码

    从截图的代码中可以看出Thread类中有个threadLocals 变量,它是ThreadLocalMap类型的,我们可以把ThreadLocalMap理解为ThreadLocal类实现的定制化的HashMap,默认情况下它是null,只有当前线程调用ThreadLocal的set或者get方法时才创建他们。实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的get/set方法。

    下面是get/set的源码:

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

    注意看这个set其实就是获取当前线程的threadLocals、然后如果为null 的话说明这个线程没调用过ThreadLocal 类的get/set方法,所以初始化这个map,并且把这次修改后的值存进去。如果之前有的话直接修改这个ThreadLocal 的值。
    然后get源码就更简单了:

        public T get() {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null) {
                ThreadLocalMap.Entry e = map.getEntry(this);
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    T result = (T)e.value;
                    return result;
                }
            }
            return setInitialValue();
        }
    

    从map里获取这个对象对应的值,没有的话返回null。
    通过上面的源码我们可以得出结论:最终的变量是放在了当前线程的ThreadLocalMap中。并不是存在ThreadLocal上。ThreadLocal可以理解为是ThreadLocalMap的封装,传递了变量值。
    ThreadLocal通过Thread.currentThread()获取当前线程对象,直接通过getMap访问该线程的ThreadLocalMap对象。
    每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为key,Object对象为value的键值对。

    ThreadLocal内存泄漏问题

    ThreadLocalMap中使用key为ThreadLocal的弱引用。而value是强引用。所以如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉。这样一样,ThreadLocalMap中就会出现key为null的Entry.
    假如不做任何措施的话,value永远无法被GC回收。这个时候就会产生内存泄漏。
    ThreadLocalMap已经考虑了这种情况,所以最好的办法是使用完这个ThreadLocal方法后,调用remove()方法。


    remove方法

    需要注意的是,这个remove方法虽然是ThreadLocal的,但是remove生效是指删除当前线程中这个ThreadLocal的键值对。

    本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注。也祝大家工作顺顺利利,生活健健康康~!

    相关文章

      网友评论

        本文标题:JavaGuide知识点整理——并发进阶知识点(上)

        本文链接:https://www.haomeiwen.com/subject/cfijirtx.html