JMM基础

发布时间 2023-11-03 12:16:33作者: 唐钰逍遥

指令重排

  • volatile

    • 防止指令重排(内存栅栏)
      保证之前的指令一定能全部执行,之后的指令一定都没有执行。

      实例化对象步骤:局部变量压栈-对象实例化-对象地址指向变量。

      如果线程够多被调用够快由于本身jvm优化的原因,会出现,第二步和第三步的重排序现象,如果不对需要被多个线程高频次访问的成员变量加volatile关键字就会出现空指针异常。

    package com.upsmart.utils;
    
    /**
     * 双锁单例防止指令重排
     */
    public class DCKSingle {
    
        private static volatile DCKSingle instance;
    
        private DCKSingle(){
            System.out.println("实例化");
        };
    
        public static DCKSingle getInstance() {
            if(instance == null){
                synchronized (DCKSingle.class){
                    if(instance == null){
                        instance = new DCKSingle();
                    }
                }
            }
            return instance;
        }
    
        public static void main(String[] args) {
            Thread threadA = new Thread(new Runnable() {
                public void run() {
                    DCKSingle.getInstance();
                }
            });
            Thread threadB = new Thread(new Runnable() {
                public void run() {
                    DCKSingle.getInstance();
                }
            });
            threadA.start();
            threadB.start();
    
    
        }
    }
    
    
    • 线程间可见
      加了volatile关键字的变量在线程间但凡有其中一个线程对其改动会把改动后的值从工作内存save到主内存中(这也就保证了变动是线程间可见的。);同时其他线程中volatile关键字是无效的,

      package com.upsmart.utils;
      
      /**
       * 线程间可见
       */
      public class BetweenThreadVisible {
          private static volatile  int a = 0;
          public static void main(String[] args) throws InterruptedException {
      
              Thread thread = new Thread(new Runnable() {
                  public void run() {
                      while (a==0);
                      System.out.println("死循环啦");
                  }
              });
              thread.start();
              Thread.sleep(1000);
              a = 1;
              System.out.println("跳出循环了");
          }
      }
      
      

      不加volatile关键字会导致死锁

      1650619244813

      但是它保证不了原子性,例如多线程的i++操作就会有问题。

锁机制

用法

  • lock
    lock unlock
    unlock 随机唤醒一个 unlockall 唤醒所有
  • condition
    await signal

量级

  • 重量级锁

    public static void main(String[] args) {
        synchronized (Main.class) {
            //这里使用的是Main类的Class对象作为锁
        }
    }
    

    image-20220302111724784

    其中最关键的就是monitorenter指令了,可以看到之后也有monitorexit与之进行匹配(注意这里有2个),monitorenter和monitorexit分别对应加锁和释放锁,在执行monitorenter之前需要尝试获取锁,每个对象都有一个monitor监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。

    需要向操作系统申请互斥资源,有系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换。
    自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。在JDK6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。

    在代码执行完成之后,我们可以看到,一共有两个monitorexit在等着我们,那么为什么这里会有两个呢,按理说monitorenter和monitorexit不应该一一对应吗,这里为什么要释放锁两次呢?

    首先我们来看第一个,这里在释放锁之后,会马上进入到一个goto指令,跳转到15行,而我们的15行对应的指令就是方法的返回指令,其实正常情况下只会执行第一个monitorexit释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个,其实是用来处理异常的,可以看到,它的位置是在12行,如果程序运行发生异常,那么就会执行第二个monitorexit,并且会继续向下通过athrow指令抛出异常,而不是直接跳转到15行正常运行下去。

  • 轻量级锁
    在无竞争情况下,减少重量级锁产生的性能消耗, 赌同一时间只有一个线程在占用资源, 首先检查对象的Mark Word,查看锁对象是否被其他线程占用,如果没有任何线程占用,那么会在当前线程中所处的栈帧中建立一个名为锁记录(Lock Record)的空间,用于复制并存储对象目前的Mark Word信息(官方称为Displaced Mark Word)。 接着,虚拟机将使用CAS操作将对象的Mark Word更新为轻量级锁状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧) 。
    CAS(Compare And Swap)实现, 在CPU中,CAS操作使用的是cmpxchg指令,能够从最底层硬件层面得到效率的提升。

  • 偏向锁

    偏向锁相比轻量级锁更纯粹,干脆就把整个同步锁过程都消除掉,不需要再进行CAS操作了。

    偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。
    如果我们需要使用偏向锁,可以添加-XX:+UseBiased参数来开启。

    image-20220302214647735

    未锁定 < 偏向锁 < 轻量级锁 <自旋锁< 重量级锁

实现

  • 可重入锁(排它锁)
    可获取多个锁,可是多个线程不可以共享锁,同一个时间互斥的锁只能被一个线程使用。

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        lock.lock();   //连续加锁2次
        new Thread(() -> {
            System.out.println("线程2想要获取锁");
            lock.lock();
            System.out.println("线程2成功获取到锁");
        }).start();
        lock.unlock();
        System.out.println("线程1释放了一次锁");
        TimeUnit.SECONDS.sleep(1);
        lock.unlock();
        System.out.println("线程1再次释放了一次锁");  //释放两次后其他线程才能加锁
    }
    
  • 读写锁
    读写互斥 读读不互斥 写写互斥
    可以先加写锁然后加读锁,也就是说支持锁降级。
    但是先加读锁再加写锁就不行,不支持锁升级。

    public static void main(String[] args) throws InterruptedException {
        ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
    
        Runnable action = () -> {
            System.out.println("线程 "+Thread.currentThread().getName()+" 将在1秒后开始获取锁...");
            lock.writeLock().lock();
            System.out.println("线程 "+Thread.currentThread().getName()+" 成功获取锁!");
            lock.writeLock().unlock();
        };
        for (int i = 0; i < 10; i++) {   //建立10个线程
            new Thread(action, "T"+i).start();
        }
    }
    

并发容器

  • CopyOnWriteArrayList

    • 写加锁
    public boolean add(E e) {
            final ReentrantLock lock = this.lock;
            lock.lock();
            try {
                Object[] elements = getArray();
                int len = elements.length;
                //复制写
                Object[] newElements = Arrays.copyOf(elements, len + 1);
                newElements[len] = e;
                //写回
                setArray(newElements);
                return true;
            } finally {
                lock.unlock();
            }
        }
    
  • ConcurrentHashMap
    hashmap 数据结构
    key的数据结构为hashtable,本质就是一个数组,key为hash后的key,value指向不同的value组。
    value的数据结构,当value的长度小于8时,数据结构为单向链表;长度 达到8 时,就会转为红黑树便于插入和查找。
    img

    image-20220308230825627

    综上,ConcurrentHashMap的put操作,实际上是对哈希表上的所有头结点元素分别加锁,理论上来说哈希表的长度很大程度上决定了ConcurrentHashMap在同一时间能够处理的线程数量,这也是为什么treeifyBin()会优先考虑为哈希表进行扩容的原因。显然,这种加锁方式比JDK7的分段锁机制性能更好。

问: 当进行写入操作时,ConcurrentHashMap 会对涉及到的段进行互斥锁定 。

import java.util.concurrent.ConcurrentHashMap;

public class Example {
    private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        // 启动多个线程并发地进行写入操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                map.put("Key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 5; i < 10; i++) {
                map.put("Key" + i, i);
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程执行结束
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终的 ConcurrentHashMap
        System.out.println(map);
    }
}
// 输出
// {Key4=4, Key5=5, Key6=6, Key7=7, Key8=8, Key9=9, Key0=0, Key1=1, Key2=2, Key3=3}

问:多个线程同时通过 put 方法修改相同的键 "Key" 对应的值,为什么会造成不确定的结果?

回答:尽管 ConcurrentHashMap 使用分段锁(Segment-Level Locking)来保证并发写入操作的线程安全性,但当多个线程同时修改相同的键时,仍可能出现不确定的结果。这是因为 put 方法本身并没有提供原子性,导致多个线程的执行顺序无法确定,最后一个成功的 put 操作会覆盖之前的结果,或者一个线程在执行过程中被另一个线程抢占。

可以理解为在多线程环境下,读操作(get)之间是不需要互斥的,因此多个线程可以同时读取相同的键。然而,写操作(put)需要确保原子性以避免数据错乱。

当多个线程同时进行写入操作时,每个线程都会读取当前键 "Key" 对应的值,并在自己的线程中修改该值,然后再将修改后的值写回 ConcurrentHashMap。由于写入操作不是原子化的,多个线程可能会交叉执行这些步骤,导致最终结果不确定。

例如,线程 A 和线程 B 同时读取键 "Key" 对应的值,得到分别为 1 和 2。然后线程 A 将其值加 1,得到 2,并尝试写回到 ConcurrentHashMap,但此时线程 B 也完成了读取和修改操作,将其值加 1 得到 3,并将其写回 ConcurrentHashMap。由于线程 B 最后一次写入,键 "Key" 的值最终被覆盖为 3,而线程 A 的写入操作被覆盖,导致最终结果为 3。

这种情况下,虽然读操作是并发安全的,但写操作之间的竞争条件导致了键值对的不确定性和覆盖结果。

为了解决这个问题,您可以使用原子化的操作方法,如 putIfAbsentcompute,它们能够确保对键值对进行原子化的更新。这样,多个线程同时修改相同的键时,只有一个线程能够成功将其更新的值写回 ConcurrentHashMap,避免了不确定性和覆盖问题。

问:为什么只有一个线程能够成功将其更新的值写回 ConcurrentHashMap?

回答:在 JDK 1.8 中,ConcurrentHashMap 使用了基于 CAS 和 Synchronized 的并发控制策略。通过使用 putIfAbsent 方法,只有一个线程能够成功将其更新的值写回 ConcurrentHashMap。当第一个线程调用 putIfAbsent 时,如果键已经存在,则不会进行写入操作;而当第二个线程调用 putIfAbsent 时,由于键已存在,也不会进行写入操作。因此,最终输出的结果是第一个成功写入的线程所设置的值,其他线程的写入操作被忽略。

import java.util.concurrent.ConcurrentHashMap;

public class Example {
    private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        // 启动多个线程并发地进行写入操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.putIfAbsent("Key", i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.putIfAbsent("Key", i);
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程执行结束
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终的 ConcurrentHashMap
        System.out.println(map.get("Key"));
    }
}


问:JDK 1.8 的 ConcurrentHashMap 是采用分段锁还是给 Hashtable 头结点加锁?

JDK 1.8 的 ConcurrentHashMap 不是采用分段锁,而是给 Hashtable 头结点加锁的。分段锁是 JDK 1.7 的实现方式,JDK 1.8 放弃了这种方式,而是借鉴了 HashMap 的数据结构:数组+链表+红黑树。JDK 1.8 的 ConcurrentHashMap 用 CAS 乐观锁和 synchronized 锁来保证并发安全。 相比于 JDK1.7 的锁粒度,JDK1.8 的锁粒度更细,因为一个 Segment 可能包含多个节点。

问:ConcurrentHashMap 的扩容机制是怎样的?

  • ConcurrentHashMap 中的元素数量超过了扩容的阈值(sizeCtl),它会调用 transfer() 方法执行扩容操作,将原来的数组扩大为原来的两倍。
  • 扩容时,每个线程都会参与迁移数据,每个线程一次处理有限个数的哈希桶,从数组的最后一个索引位置开始,向前推进。
  • 当一个哈希桶内的全部节点都已经转移到新数组后,旧的哈希桶内的数据不发生变化,而是用一个转移节点(ForwardingNode)来占位,表示这个哈希桶已经迁移完毕。
  • 如果在扩容过程中,有其他线程执行插入、修改、删除等操作,遇到转移节点时,会先帮助扩容完成,然后再进行相应的操作。

HashMap 扩容为什么是 2 的幂是这样的:

  • HashMap 在存放元素时,会根据元素的哈希值和数组的长度减一进行按位与运算,得到元素在数组中的位置。如果数组的长度是 2 的幂,那么按位与运算相当于对长度取模,即 hash & (length - 1)
  • 如果数组的长度是 2 的幂然后减1,那么它的二进制表示中只有一个位是0,其余都是 1。这样可以使得按位与运算的结果更加均匀分布,减少哈希冲突的概率。
  • 因此,为了提高 HashMap 的性能和效率,扩容时采用 2 的幂作为新数组的长度。