高频知识点总结 - 02

本文深入探讨了Java中的volatile关键字,详细解释了其内存可见性、有序性以及不保证原子性的特点,并通过实例展示了volatile在单例模式中的应用。此外,还分析了CAS(Compare and Swap)机制,包括AtomicInteger的源码剖析,以及如何解决由此引发的ABA问题。最后讨论了Java集合线程安全问题,提出了相应的解决方案,如CopyOnWriteArrayList和ConcurrentHashMap。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

高频知识点总结 - 02

1.谈谈对volatile的理解

volatile 是 JVM 提供的一种轻量级的同步机制,它能够保证线程之间的可见性、不能保证原子性、禁止指令重排(保证有序性)。

下面补充一个概念:JMM(java money model)Java 内存模型

1.1 JMM

  • JMM 本身是一种抽象的概念,并不是真实存在,**它描述的是一组规则或规范,**通过这个规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式

  • JMM 本身要求可见性、原子性、有序性

  • JMM 是 Java 内存模型。每一个线程都有自己的工作内存,但是所有的共享变量都是存在主内存中(电脑上的物理内存),当线程要操作数据时,会先将主内存中的数据复制一份到工作内存,在操作处理完以后,在更新到主内存中

  • 本身线程自己操作是对其他线程不可见的,并且线程之间无权互相访问,线程之间的通信(数据传递)必须通过主内存来实现

  • 可见性就是当前线程在将从主内存取出的值更新将要刷回主内存时,会通知其他线程自己已将主内存中的值更新

1.1.1 JMM关于同步的规定
  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存中的最新值到自己的工作内存
  3. 加锁解锁是同一把锁并且加了多少把锁就要进行解锁多少次,成对出现

1.2 可见性实例演示

/*
* - 这里可以看到在int i 前面没有加上volatile修饰
* - 虽然线程已经将值修改,但是主线程并不知道值已经修改,则会一直处于等待状态
* 解决办法:在int i 前面加上volatile修饰,保证可见性
* */
public class VolatileDemo {
    public static void main(String[] args) {
        Volatile01 volatile01 = new Volatile01();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"线程运行");

            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            volatile01.add();
            System.out.println(Thread.currentThread().getName()+"修改值");

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

        while (volatile01.i == 10){

        }

        System.out.println(Thread.currentThread().getName()+"执行完毕");
    }
}

class Volatile01{
    int i = 10;

    public void add(){
        i = 60;
    }
}

1.2 原子性

在执行一系列的语句中,要么语句都成功,要么都失败。

1.3 volatile不具有原子性和解决办法

public class VolatileDemo02 {
    public static void main(String[] args) {
        Demo2 demo2 = new Demo2();

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    demo2.addV();
                }
            },String.valueOf(i)).start();
        }

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    demo2.addAtomic();
                }
            },String.valueOf(i)).start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }

        /*
        * 当前在i前面加了volatile修饰词但是最终运行的结果到不了20000
        * 这是因为,出现了线程中写丢失,如果第一时间通知了其他线程值已经修改,但是线程写入主内存
        * 更新的速度太快,可能还没有接收到更改的通知,就又进行了一次主内存更新,这时就会出现重复更新
        * */
        System.out.println(demo2.i);
        /*
        * 解决方式
        * - 通过原子类来解决
        * */
        System.out.println(demo2.atomicInteger);
    }
}

// 解决办法
class Demo2{
    volatile int i = 0;

    public void addV(){
        i++;
    }

    // new AtomicInteger() 括号中没有定义则默认值为0
    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic(){
        atomicInteger.getAndIncrement(); // 等同于 i++
    }
}

1.4 有序性

  • 有序性的对立是指令重排,指令重排是一种优化,提高运行效率

  • 对于单线程来说经过指令重排可以使最后的结果和有序性执行的结果相同

  • 但对于多线程就不能保证。所以虽然能够提高效率,但如果是多线程执行,可能因为重排的影响最终得到的结果不是我们想要的,那是不行的,因此就有必要在一些特定的情况下不允许指令重排的出现

  • 虽然多线程可能出现指令重排,但是在进行重排时也要考虑数据的依赖性,比如:

    int i = 0;

    i = i + 5;

这种情况下因为第二条语句对i的创建是有依赖性的,那就不能在第一条语句前面执行。

1.5 volatile实现禁止指令重排原理

  • 先了解一个概念,内存屏障(Memory Barrier)又称为内存栅栏,是一个 cpu 指令

  • 由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 memory barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 memory barrier 指令重排序,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

  • 内存屏障的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何CPU上线程都能读取到这些数据的最新版本。==》 可见性

1.5.1 作用
  1. 保证特定的操作的执行顺序(有序性)
  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

1.6 volatile 实例演示 - 单例模式

在多线程的情况下,在原本的单例模式可能会创建出多个实例。

1.6.1 实例演示
public class SingletonDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Demo.getDemo();
            },String.valueOf(i)).start();
        }
    }
}

class Demo{
    private static Demo demo = null;

    private Demo() {
        System.out.println(Thread.currentThread().getName()+"   进入构造器方法");
    }

    public static Demo getDemo(){
        if (demo == null)
            demo = new Demo();
        return demo;
    }
}

运行结果会出现创建出多个实例对象。

1.6.2 解决方式
  1. DCL(double check lock)双端检锁机制
  2. DCL + Volatile
1.DCL(double check lock)双端检锁机制

在锁的前后进行检查

public static Demo getDemo(){
    if (demo == null)
        synchronized (Demo.class){
            if (demo == null){
                demo = new Demo();
            }
        }
    return demo;
}
2.问题
  • 虽然加入了双端检锁(在加锁的前后进行两次检查),但是当是多线程运行的时候,可能会出现指令重排的现象

  • 顺序执行是:

    1. 分配空间
    2. 对象完成初始化
    3. 初始化对象指向分配空间
  • 指令重排:

    1. 分配空间
    2. 对象指向分配空间
    3. 对象完成初始化

就会出现在于某一线程执行到第一次检测,读取到demo不为null时,但是当前demo的引用对象可能没有完成初始化,则就会造成,虽然当前分配给demo实例的内存空间不为空,但是其他线程此时读取的数据却为空的情况。

为了避免这种情况,我们采用第二种解决方式,在唯一实例上面加上volatile,禁止出现指令重排的现象。

3.DCL + Volatile
private volatile static Demo demo;

2.CAS

  • compareandswap(比较并交换),AtomicInteger 等类似实现原子性的底层原理

  • 比较当前工作内存中的值和主内存中的值,如果预期的值和当前内存中的值相同,则执行下一步操作,否则继续比较直到主内存和工作内存中预期的值一致为止

  • 它是一条 CPU 并发原语

  • 它的功能是判断内存某个位置的值是否为预期值,如果是则进行更新,这个过程是原子的

  • 完全依靠硬件的功能,通过 sun.misc.Unsafe 实现 CAS 原子操作

原语,系统内核操作,执行过程中不允许被打断。

2.1 AtomicInteger.compareandset()方法分析

public class CASDemo {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(5);
        System.out.println(atomicInteger.compareAndSet(5, 2021)+"\t 目前的值为:"+atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(5, 1003)+"\t 目前的值为:"+atomicInteger.get());
    }
}

/*    结果:
       true	 目前的值为:2021
       false 目前的值为:2021*/
2.1.1 compareandset源码
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

拥有一个期望值和更新值,当期望值和主内存中的值相同是进行更新,否则失败。返回类型为boolean类型。

2.2 AtomicInteger.getAndIncrement()源码分析

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

我们发现它是通过unsafe.getAndAddInt这个方法实现

1.参数解析
  • this:当前对象

  • valueOffset:内存地址偏移量,也就是当前对象的内存地址

  • 1:在当前对象上增加1

2.2.1 unsafe.getAndAddInt()分析
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
  • 传入对象、对象地址和增加量,第一步先获取到对象当前的值赋值给 var5

  • 下一步进行增加,判断如果当前地址上的值和得到的 var5 相同则进行增加,否则继续循环获得当前对象上的值,直到在比对是的值和当前地址上的值相同为止

    依靠自旋来实现类似锁同步的机制

  • private volatile int value;值本身是用volatile修饰的,当前线程修改不成功时,也会收到被修改后更新的通知。

  • 进行下一步,将地址上的进行更新

如果继续往下寻找会发现public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);是一个本地方法,本地方法通过调用 c 语言的指针来直接操作内存地址上的变量,并且这个过程是不可中断的,具有原子性。

2.2.2 compareAndSwapInt()方法底层

compareAndSwapInt() 是一个本地方法,源码在 unsafe.cpp 代码中

2.3 CAS和synchronized优点

如果通过synchronized关键实现,虽然实现了原子性,但是只允许单线程进行操作则会降低并发性。

2.3.1 CAS缺点
  1. 循环时间长开销大

    如果一直发现值都不同则会一直比较,自旋(CAS实现 + UnSafe)

  2. 只能保证一个共享变量的原子操作

    synchronized可以锁住代码块

  3. 引出的ABA问题

2.3.2 引出的ABA问题
  • 同时两个线程操作主内存中的值,A线程处理时间为10秒

  • B 线程处理的时间为2秒。某一时刻主内存中的值为1,两个线程同时拷贝回自己的工作内存,B 先将主内存中的值从 1 改为了 2

  • 但在 A 线程处理的时间内,B 线程又将主内存的值从 2 改为了 1

  • 改完后,A 线程来更新值,发现和原先的 1 相同,于是就进行了值修改,将主内存中的值从1改为3

  • 只是值相同,但是实际上值1本身已经发生改变

    此时的 1 已经不是原本的 1,数字可能看起来影响不大,但是我们把他扩大到实际业务中。

  • 表面上看是没有问题的,但实际上这就造成了ABA问题

解决ABA问题,我们先看看自定义原子引用,帮助我们解决ABA问题

1.自定义原子引用

除去jdk提供的原子引用,用户自己本身也是可以自定义。

2.实例
public class AtomicReferenceDemo {
    public static void main(String[] args) {
        User zs = new User("zs", 22);
        User za = new User("za", 25);
        AtomicReference<User> userAtomicReference = new AtomicReference<>(zs);

        System.out.println(userAtomicReference.compareAndSet(zs, za)+"\t 现在的用户为:"+userAtomicReference.get().toString());
    }
}

class User{
    String name;
    int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
3.ABA问题代码实现
public class AtomicReferenceDemo02 {
    public static void main(String[] args) {
        AtomicReference<Integer> integerAtomicReference = new AtomicReference<>(5);

        new Thread(() -> {
            /*
            * 线程一比线程二块,改了值以后再改回来
            * */
            System.out.println(integerAtomicReference.compareAndSet(5, 6));
            System.out.println(integerAtomicReference.compareAndSet(6, 5));
            System.out.println("线程一,已完成修改并又将值改了回去");
        },"线程一").start();

        new Thread(() -> {
            try {
                /*
                * 模拟线程二更慢的处理
                * */
                TimeUnit.SECONDS.sleep(1);
                System.out.println(integerAtomicReference.compareAndSet(5, 2021));
                System.out.println(integerAtomicReference.get().toString());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"线程二").start();
    }
}
/*结果:
        true
        true
        线程一,已完成修改并又将值改了回去
        true
        2021*/
4.解决ABA问题

在数据上面加上版本号。

实现:通过加上时间戳。

4.1 实现演示
/*
* 思路
* - 首先保证两个线程都能获得相同的版本号
* - 让线程二的处理更慢一点
* - 线程一实现ABA
* - 再让线程前去操作
*   - 发现虽然值没有发生改变
*   - 但是版本号已经不同
* */
public class AtomicReferenceDemo03 {
    public static void main(String[] args) {
        AtomicStampedReference<Integer> as = new AtomicStampedReference<Integer>(100,1);

        new Thread(() -> {
            try {
                int stamp = as.getStamp();
                TimeUnit.SECONDS.sleep(1);
                System.out.println(as.compareAndSet(100, 101, stamp, as.getStamp() + 1));
                System.out.println(as.compareAndSet(101, 100, as.getStamp(), as.getStamp() + 1));
                System.out.println(Thread.currentThread().getName()+"\t 修改以后的值为:"+as.getReference());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"线程一").start();

        new Thread(() -> {
            try {
                int stamp = as.getStamp();
                TimeUnit.SECONDS.sleep(4);
                System.out.println(as.compareAndSet(100, 2019, stamp, stamp + 1));
                System.out.println(Thread.currentThread().getName()+"\t 当前的值为:"+as.getReference());
            } catch (InterruptedException e) {
                e.printStackTrace();

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

/*      true
        true
        线程一	 修改以后的值为:100
        false
        线程二	 当前的值为:100*/
4.2 AtomicStampedReference()解析
public AtomicStampedReference(V initialRef, int initialStamp) {
    pair = Pair.of(initialRef, initialStamp);
}

参数为:初始值和设定版本号

4.3 AtomicStampedReference.compareAndSet()方法解析
public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}
  • expectedReference:期待的值

  • newReference:更新的值

  • expectedStamp:期待的版本

  • newStamp:更新的版本

当都满足的时,才能更新,通过添加版本,解决了ABA问题

3.集合线程安全

3.1 List

3.1.1 问题

在多线程下的并发修改异常。

3.1.2 原因

arrayslist中没有线程同步机制,无法保证线程执行的原子性

3.1.3 解决
  1. new vactor

    底层通过加入synchronized关键字,性能太低,不推荐。(已弃用)

  2. Collections.synchronizedlist

    通过加入synchronized关键字

  3. new copyonwritelist(写时复制技术)

    通过加入锁(lock),将list进行复制,然后在复制的上面进行更新,更新成功后,通知其他线程用最新的list执行其他操作(推荐)

3.2 HashSet

同上(list),类似的同步 synchronizedhashset 和 copyonwritehashset。

3.2.1 关于hashset底层
  1. 底成是由hashmap实现

  2. 只有key没有value

    所有value的值都是一个值private static final Object PRESENT = new Object();

3.3 HashMap

3.3.1 解决

new concurrentHashMap(推荐,性能和 HashMap 差距不大,并且可以保证并发安全。

4.自旋锁

尝试获取锁的线程不会立即阻塞(也就是继续执行其他的操作,过一会继续来尝试获取)。而是采用循环的方式区尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。

用循环来代替阻塞。

4.1 实例

/*
* - aa线程先进入并且一直在进行执行处理
* - bb后面进入,已经不符合,一直在进行循环等待
* - aa处理完以后,其他线程才可以执行比较并交换处理
* 总结:
* 用循环来替代阻塞,循环会提高cpu消耗
* */
public class SpinLockDemo {
    AtomicReference<Thread> af =  new AtomicReference<Thread>();

    public void myLock(){
        // 获取当前在执行这个方法的线程
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName()+"进入方法");
        while (!af.compareAndSet(null,thread)){
            System.out.println(Thread.currentThread().getName()+"在执行ing");
        }
    }

    public void myUnLock(){
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName()+"释放锁");
        af.compareAndSet(thread,null);
    }

    public static void main(String[] args) throws InterruptedException {
        SpinLockDemo spinLockDemo = new SpinLockDemo();

        new Thread(() -> {
            try {
                spinLockDemo.myLock();
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                spinLockDemo.myUnLock();
            }
        },"aa").start();

        // 让主线程休眠1s,确保aa线程先执行
        TimeUnit.SECONDS.sleep(1);

        new Thread(() -> {
            spinLockDemo.myLock();
            spinLockDemo.myUnLock();
        },"bb").start();
    }
}

5.枚举

可以作为一个简单的数据库使用

public enum EnumDemo {
    ONE(1,"齐国"),TWO(2,"齐国1"),THREE(3,"齐国3"),FOUR(4,"齐国4"),FIVE(5,"齐国5"),SIX(6,"齐国6");

    private Integer index;
    private String name;

    EnumDemo(Integer index, String name) {
        this.index = index;
        this.name = name;
    }

    public static  EnumDemo getElement(int index){
        EnumDemo[] arr = EnumDemo.values();

        for (EnumDemo enumDemo : arr) {
            if (index == enumDemo.getIndex()){
                return enumDemo;
            }
        }

        return null;
    }

    public Integer getIndex() {
        return index;
    }

    public String getName() {
        return name;
    }
}

比如第一张表叫做ONE,里面的属性就是字段。

6.GC ROOT对象

枚举根节点做可达性发现(根搜索路径),gc root是一个set集合,以下是可以作为 GC ROOT 的对象来源:

  • 虚拟机栈中局部变量表所引用的对象
  • 方法区的类静态变量引用的对象
  • 方法区常量引用的对象
  • 本地方法栈中引用的对象

7.假如生产环境出现的CPU占用过高,谈谈分析思路和定位?

  1. 先找出cpu占用最高

  2. ps -ef或jps -l找到具体的程序

  3. 定位到具体线程或代码

    ps -mp [进程号] -o THREAD,tid,time

    -o:用户自定义的显示方式

  4. 线程ID转换为16进制格式

    通过系统自带的计算机转换

  5. 通过jstack定位

    jstack [进程号] | grep [16进制线程id] -A60

    -A60:打印前60行

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值