什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!

文章目录

    • 线程安全的定义
    • 线程安全的三大特性
      • 原子性
        • 举例原子性问题的程序
        • 解决上述原子性问题的手段
        • volatile关键字无法保证原子性
      • 可见性
        • Java线程内存模型
          • CPU缓存模型
          • JMM内存模型(工作内存+主内存)
        • 举例可见性问题的程序
        • 解决上述可见性问题的手段
      • 有序性
        • Happens-before原则
        • 举例出现有序性问题的程序
        • 解决上述有序性问题的手段
      • 总结
        • 线程不安全的原因
    • 五种线程安全类型
      • 不可变类
        • 如何实现不可变?
        • 常见的不可变的类型
      • 绝对线程安全类
        • 如何实现绝对线程安全
      • 有条件线程安全类
      • 线程兼容类
      • 线程对立类
    • 证明StringBuffer线程安全,StringBuilder线程不安全
      • 测试思想
      • 测试代码
      • 测试结果
      • 源码分析
    • HashMap为啥是非线程安全的【基于JDK1.7】
      • 问题提出
      • 原因分析
      • HashMap相关源码实现
      • 案例分析
      • 总结
    • HashTable为啥是线程安全的
    • ConcurrentHashMap是如何实现线程安全的
      • ConcurrentHashMap是如何实现线程安全的
      • ConcurrentHashMap一定是线程安全的吗?

线程安全的定义

线程安全概念:线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

线程安全的类:当多个线程访问某个类的方法或者类的实例对象时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协调操作(自身已经实现同步),这个类的方法的执行或者类的实例对象的修改,都能按照预期的结果反馈,那么这个类就是线程安全的。

或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。那么这个类或者程序所提供的接口就是线程安全的。

线程安全的三大特性

原子性

多线程中的原子性,即一个操作或多个操作要么全部执行并且执行过程不能被打断,或者要么全部不执行。

举例原子性问题的程序

典型的n++,n–操作:

两个线程A和B,分别对共享变量0执行+1操作1000次和-1操作一千次,等待两个线程都执行完,打印共享变量的值。
对于理想结果肯定是0,但是真实的结果每次都是随机数(是CPU调度线程是随机的)

package com.fastech.interview;

public class ThreadAtomicity {

    private static int n = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 1000;i++){
                n++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 1000;i++){
                n--;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(n);
    }
}

原因是我们在写的一行Java代码可能不是原子性的,因为它编译成字节码,或者由JVM把字节码翻译为机器码后就不是一行,也就是多条执行操作。因为一次++或者–操作是分三步执行:

  1. 从内存把数据读到CPU

  2. 对数据进行更新操作

  3. 再把更新后的操作写入内存

我们以n++为例,执行过程以及内存情况见下图所示:

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第1张图片

现在回到上面代码,当我们使用start方法启动两条线程,每个线程在执行n++操作或者n–操作时,CPU可能随时切换,比如:线程A在执行++操作时刚执行了两条指令(load n;和add R1 1)CPU就从线程A切换出去,此时save n;指令并没有执行;对于线程B,从内存中拿到的数据此时并不是线程A执行n++操作后的结果(++后的结果并没有写回内存),所以就破坏了线程A的原子性,导致数据不是预期的结果。

解决上述原子性问题的手段

  1. synchronized锁

  2. lock锁

  3. Atomic类型

    具体操作

    package com.fastech.interview;
    
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class ThreadAtomicInteger {
    
        private static AtomicInteger ai = new AtomicInteger(0);
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(() -> {
                for(int i = 0;i < 10000;i++){
                    ai.incrementAndGet();
                }
            });
            Thread t2 = new Thread(() -> {
                for(int i = 0;i < 10000;i++){
                    ai.decrementAndGet();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(ai.get());
        }
    
    }
    

volatile关键字无法保证原子性

以n++为例子:分为三步:读 计算 写

首先主存中有一个变量n = 0。

线程1从主存中读取变量n,并拷贝一份n = 0的副本到自己的高速缓存中。接下来执行n+1操作。自己副本中n = 1。接下来本应该强制将修改的值立即写入主存,但是时间片用完了(时间片用完了就必须停止,这里还没有来得及写入主存),线程1阻塞了。

然后线程2从主存中读取变量n,并拷贝一份n = 0的副本到自己的高速缓存中。接下来执行n+1操作,自己副本中的n = 1。接下来将n= 1写入主存。在写入主存过程中会通过一个总线嗅探机制告诉其它线程的副本n,你失效了。

过一会线程1又分配到了时间片,这个时候应该强制将修改的值立即写入主存,但是去高速缓存中拿值的时候发现自己的副本已经被标记失效了。然后就得重新去主存中拿n值。重新到主存中拿到的值n = 1。最后把n = 1写入主存。(读和计算的操作是已经执行过了的命令,所以它不会重新计算了,它只差写操作的命令)

本来是两个线程,分别对n执行+1操作,正确值应该为2,但是最终为1,显然是有问题的。

所以volatile只能保证变量的可见性,不能保证原子性。

n++是三个原子操作组合而成的复合操作。它本身就不是原子操作,不能依靠volatile这个保证可见性和有序性的关键字来保证原子性。

可见性

当多个线程访问同一变量的时候,其中一个线程修改了这个变量,其他线程必须要立刻知道这个变量被修改的值。

Java线程内存模型

Java线程内存模型(JMM)是基于Cpu缓存模型建立的,它的作用是屏蔽掉不同硬件和操作系统的内存访问差异,实现各种平台具有一致的并发效果。

CPU缓存模型

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第2张图片

开始CPU是直接和主存进行交互的,但这样会有一个很大的问题,CPU的计算速度非常快,而主存是硬盘操作,比起CPU会慢很多很多,有时候CPU需要等待主存,导致效率很低。所以在CPU和主存之间加一个高速缓存作为缓冲,虽然高速缓存和CPU之间还存在速度差别,但比直接访问主存的效率高的多。

ps:这里的高速缓存是分成多级缓存,这里只是了解,简单画了一下。

JMM内存模型(工作内存+主内存)

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第3张图片

多个线程工作的时候都是在**自己的工作内存中(CPU寄存器)**来执行操作的,不能直接操作主内存,线程之间是不可见的。

  1. 线程之间的共享变量存在主内存
  2. 每一个线程都有自己的工作内存
  3. 线程读取共享变量时,先把变量从主存拷贝到工作内存(寄存器)再从工作内存(寄存)读取数据
  4. 线程修改共享变量时,先修改工作内存中的变量值再同步到主内存

由于工作内存与主内存同步延迟,带来了可见性问题

举例可见性问题的程序

下例程序有可见性问题:

package com.fastech.interview;

public class ThreadVisibility {

    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!flag) {
            }
            System.out.println("t线程执行完毕");
        }, "t").start();

        Thread.sleep(500);
        flag = true;
        System.out.println("main线程执行完毕");
    }

}

t线程中的while读取的是flag。在main线程中有设置成true,但是A线程的while 循环迟迟不退出,证明A线程读取的flag的值为flase,并没有读到最新的值。

解决上述可见性问题的手段

  1. volatile关键字private static volatile boolean flag = false;,将变量用这个关键字修饰,就可以保证另一个线程修改这个值时对当前线程可见。

  2. synchronized锁:如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从工作内存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将工作内存中的数据同步到主内存。

    具体操作是在while循环里面添加以下代码synchronized (ThreadVisibility.class) {}

  3. lock锁:Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对工作内存做一个同步到主内存的操作

    Lock锁是基于volatile实现的。Lock锁内部在进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作

    如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从工作内存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他工作内存行中的这个数据设置为无效,必须重新从主内存中拉取(缓存一致性协议)

    具体实现

    package com.fastech.interview;
    
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    public class ThreadVisibility {
    
        private static boolean flag = false;
    
        private static Lock lock = new ReentrantLock();
    
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                while (!flag) {
                    try {
                        lock.lock();
                    } finally {
                        lock.unlock();
                    }
                }
                System.out.println("t线程执行完毕");
            }, "t").start();
    
            Thread.sleep(500);
            flag = true;
            System.out.println("main线程执行完毕");
        }
    
    }
    
    
  4. Atomic类型

    具体操作

    package com.fastech.interview;
    
    import java.util.concurrent.atomic.AtomicBoolean;
    
    public class ThreadVisibility2 {
    
        private static AtomicBoolean flag = new AtomicBoolean(false);
    
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                while (!flag.get()) {
                }
                System.out.println("t线程执行完毕");
            }, "t").start();
    
            Thread.sleep(500);
            flag.set(true);
            System.out.println("main线程执行完毕");
        }
    
    }
    

有序性

有序性指程序执行的顺序按照代码的先后顺序执行。

那为什么会出现不一致的情况呢?这是由于指令重排序的缘故。

在执行程序时,为了提高性能,编译器和处理器常常会做指令重排序;指令重排序不会影响单线程的执行结果,但是在并发情况下,可能会出现诡异的BUG。

Happens-before原则

先天有序性,即不需要任何额外的代码控制即可保证有序性,java内存模型一共列出了八种Happens-before规则如果两个线程不能从 happens-before原则观察出来,那么就不能观察他们的有序性,虚拟机可以随意的对他们进行重排序,导致其结果杂乱无序。

  1. 程序次序规则:一个线程内,按照代码执行,书写在前面的操作先行发生于书写在后面的操作
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  5. 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()方法返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

ps:第一条规则要注意理解,这里只是程序的运行结果看起来像是顺序执行,虽然结果是一样的,但是jvm为了提高性能可能会进行指令重排序,而jvm在单线程下的指令重排序是不会影响到运行结果的,多线程下的指令重排序是会产生线程安全的问题的。

举例出现有序性问题的程序

下列程序就会产生指令重排序

package com.fastech.interview;

public class ThreadOrderliness {

    private static int x, y;
    private static int a, b;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(() -> {
                a = 1; // step1
                x = b; // step2
            });
            Thread t2 = new Thread(() -> {
                b = 1; // step3
                y = a; // step4
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            if (x == 0 && y == 0) {
                System.out.println("第" + i + "次:x=" + x + ",y=" + y);
                break;
            }
        }
    }

}

我电脑上的运行结果:第3918次:x=0,y=0

仔细看上诉代码,正常来说只有三个结果:[10],[01],[11],但是为什么会出现[00]呢?

这就是典型的指令重排序了,出现这样的结果就必须保证step2或者step4先于step1和step3执行!

解决上述有序性问题的手段

  1. 通过thread的join方法保证多线程的顺序执行

    具体操作

    package com.fastech.interview;
    
    /**
     * @ClassName: ThreadOrderliness
     * @Description:
     * @Author: zhangjin
     * @Date: 2023/3/28
     */
    public class ThreadOrderliness {
    
        private static int x, y;
        private static int a, b;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            for (; ; ) {
                i++;
                x = 0;
                y = 0;
                a = 0;
                b = 0;
                Thread t1 = new Thread(() -> {
                    a = 1;
                    x = b;
                });
                t1.start();
                t1.join();
                Thread t2 = new Thread(() -> {
                    b = 1;
                    y = a;
                });
                t2.start();
                t2.join();
                if (x == 0 && y == 0) {
                    System.out.println("第" + i + "次:x=" + x + ",y=" + y);
                    break;
                }
            }
        }
    
    }
    

main方法里面先后运行t1,t2,那么t1.start()之后,运行t1.join()。这是会让主线程mian等待新的线程t1执行完了,再执行主线程mian下面的代码,t1.join()是让主线程main wait。

  1. volatile关键字,声明下x,y,a,b变量时加上volatile关键字即可

  2. synchronized锁

  3. Lock锁

ps:Atomic类型无法保证有序性。可以将上面例子中的x,y,a,b变量都换成AtomicInteger类型进行验证!

总结

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MB0slifb-1679996145813)(.\picture\13965490-6f512bb9647230f6.webp)]

线程不安全的原因

  1. 线程是抢占式的执行,线程间的调度充满了随机性
  2. 多个线程对共享变量进行修改操作
  3. 对变量的操作不是原子性的
  4. 内存可见性导致的线程安全
  5. 指令重排序也会影响线程安全

上面五点总结来说,线程不安全的本质原因就是有竞态条件。对于共享资源的修改不能做到原子化、对顺序敏感的时候就会导致线程不安全,上面的所有情况根因都是这个,只是表现形式不同而已。

所以,基本上掌握了这个本质,就可以分析所有线程不安全的情况。

ps:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。

五种线程安全类型

线程安全性的分类包括:不可变、绝对线程安全、有条件线程安全、线程兼容和线程对立。只要明确地记录下线程安全特性,那么您是否使用这种系统都没关系。这种系统有其局限性 – 各类之间的界线不是百分之百地明确,而且有些情况它没照顾到 – 但是这套系统是一个很好的起点。这种分类系统的核心是为了让调用者明确是否可以或者必须用外部同步操作(或者一系列操作)来确保线程安全。

不可变类

一个不可变的对象只要构建正确, 其外部可见状态永远不会改变, 永远也不会看到它处于不一致的状态。

不可变(Immutable) 的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

如何实现不可变?

  • 如果共享数据是基本数据类型,使用final关键字对其进行修饰,就可以保证它是不可变的。
  • 如果共享数据是一个对象,要保证对象的行为不会对其状态产生任何影响
  • String类是不可变的,对其进行substring()、replace()、concat()等操作,返回的是新的String对象,原始的String对象的值不受影响。而如果对StringBuffer或者StringBuilder对象进行substring()、replace()、append()等操作,直接对原对象的值进行改变。
  • 要构建不可变对象,需要将内部状态变量定义为final类型。如java.lang.Integer类中将value定义为final类型。
private final int value;

常见的不可变的类型

  • final关键字修饰的基本数据类型
  • 枚举类型、String类型
  • 常见的包装类型:Short、Integer、Long、Float、Double、Byte、Character、Boolean
  • 大数据类型:BigInteger、BigDecimal
  • 对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
    • 通过Collections.unmodifiableMap(map)获的一个不可变的Map类型。
    • Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
    • 例如,如果获得的不可变map对象进行put()、remove()、clear()操作,则会抛出UnsupportedOperationException异常。

ps:原子类 AtomicInteger 和 AtomicLong 等都是可变的。

绝对线程安全类

线程安全性类的对象操作序列( 读或写其公有字段以及调用其公有方法) 都不会使该对象处于无效状态, 即任何操作都不会违反该类的任何不可变量、前置条件或者后置条件。

如何实现绝对线程安全

绝对线程安全的实现,通常需要付出很大的、甚至不切实际的代价。Java API中提供的线程安全,大多数都不是绝对线程安全

例如,对于数组集合Vector的操作,如get()add()remove()都是有synchronized关键字修饰。但是在多线程同时调用时也需要手动添加同步手段,保证多线程的安全。

下面的代码看似不需要同步,实际运行过程中会报错。

package com.fastech.interview;

import java.util.Vector;

public class VectorTest {
    public static void main(String[] args) {
        Vector<Integer> vector = new Vector<>();
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            new Thread(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println("获取vector的第" + i + "个元素: " + vector.get(i));
                }
            }).start();
            new Thread(() -> {
                for (int i=0;i<vector.size();i++){
                    System.out.println("删除vector中的第" + i+"个元素");
                    vector.remove(i);
                }
            }).start();
            while (Thread.activeCount()>20) {
                return;
            }
        }
    }
}

出现ArrayIndexOutOfBoundsException异常,原因:某个线程恰好删除了元素i,使得当前线程无法访问元素i。

Exception in thread "Thread-3" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 28
	at java.util.Vector.remove(Vector.java:836)
	at com.fastech.interview.VectorTest.lambda$main$1(VectorTest.java:26)
	at java.lang.Thread.run(Thread.java:748)

需要将对元素的get和remove构造成同步代码块:

new Thread(() -> {
  synchronized (vector) {
    for (int i = 0; i < vector.size(); i++) {
      System.out.println("获取vector的第" + i + "个元素: " + vector.get(i));
    }
  }
}).start();
new Thread(() -> {
  synchronized (vector) {
    for (int i=0;i<vector.size();i++){
      System.out.println("删除vector中的第" + i+"个元素");
      vector.remove(i);
    }
  }
}).start();

有条件线程安全类

有条件的线程安全类对于单独的操作可以是线程安全的, 但是某些操作序列可能需要外部同步。

大部分的线程安全类都属于有条件线程安全,如Java容器中的VectorHashTableConcurrentHashMap、通过Collections.synchronizedXXX()方法包装的集合等。

线程兼容类

线程兼容类不是线程安全的, 但可以通过正确使用同步从而在并发环境中安全地使用。或用一个synchronized 块包含每一个方法调用。

如常见的ArrayListHashMap等都是线程兼容的。

线程对立类

线程对立类是那些不管是否调用了外部同步都不能在并发使用时保证其安全的类。

线程对立类很少见, 当类修改静态数据,而静态数据会影响在其它线程中执行的其它类的行为时, 通常会出现线程对立。

线程对立的常见操作有:Thread类的suspend()resume()(已经被JDK声明废除),System.setIn()System.setOut()等。

证明StringBuffer线程安全,StringBuilder线程不安全

我们都知道StringBuffer线程安全,StringBuilder线程不安全,但是如何证明呢?

测试思想

分别用1000个线程写StringBuffer和StringBuilder,
使用CountDownLatch保证在各自1000个线程执行完之后才打印StringBuffer和StringBuilder长度,
观察结果。

测试代码

package com.fastech.interview;

import java.util.concurrent.CountDownLatch;

public class Test {

    public static void main(String[] args) {
        StringBuilder stringBuilder = new StringBuilder();
        StringBuffer stringBuffer = new StringBuffer();
        CountDownLatch latch1 = new CountDownLatch(1000);
        CountDownLatch latch2 = new CountDownLatch(1000);
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    stringBuilder.append(1);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    latch1.countDown();
                }
            }).start();
        }
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    stringBuffer.append(1);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    latch2.countDown();
                }
            }).start();
        }
        try {
            latch1.await();
            System.out.println(stringBuilder.length());
            latch2.await();
            System.out.println(stringBuffer.length());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

测试结果

StringBuffer不论运行多少次都是1000长度。
StringBuilder绝大多数情况长度都会小于1000。
StringBuffer线程安全,StringBuilder线程不安全得到证明。

源码分析

打开StringBuffer源码就会发现所有写操作都被synchronized修饰了,所以所有修改操作都是串行的。
而StringBuilder的写操作则没有使用synchronized进行修饰,也不包含其他串行化修改的算法。

HashMap为啥是非线程安全的【基于JDK1.7】

问题提出

由于HashMap并非是线程安全的,所以在高并发的情况下必然会出现问题,这是一个普遍的问题,接下来了解这个死循环是如何产生的。

如果是在单线程下使用HashMap,自然是没有问题的,如果后期由于代码优化,这段逻辑引入了多线程并发执行,在一个未知的时间点,会发现CPU占用100%,居高不下,通过查看堆栈,你会惊讶的发现,线程都Hang在hashMap的get()方法上,服务重启之后,问题消失,过段时间可能又复现了。

这是为什么?

原因分析

在了解来龙去脉之前,我们先看看HashMap的数据结构。

在内部,HashMap使用一个Entry数组保存key、value数据,当一对key、value被加入时,会通过一个hash算法得到数组的下标index,算法很简单,根据key的hash值,对数组的大小取模 hash & (length-1),并把结果插入数组该位置,如果该位置上已经有元素了,就说明存在hash冲突,这样会在index位置生成链表。

如果存在hash冲突,最惨的情况,就是所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n),所以元素的hash值算法和HashMap的初始化大小很重要。

当插入一个新的节点时,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到阈值,会对数组进行扩容,也会对链表中的元素进行rehash。

HashMap相关源码实现

HashMap的put方法实现:

1、判断key是否已经存在

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    // 如果key已经存在,则替换value,并返回旧值
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // key不存在,则插入新的元素
    addEntry(hash, key, value, i);
    return null;
}

2、检查容量是否达到阈值threshold

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

如果元素个数已经达到阈值,则扩容,并把原来的元素移动过去。

3、扩容实现

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ...

    Entry[] newTable = new Entry[newCapacity];
    ...
    transfer(newTable, rehash);
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

这里会新建一个更大的数组,并通过transfer方法,移动元素。

  void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。

案例分析

假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容。

为了验证效果,假设负载因子是1。

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

以上是节点移动的相关逻辑。

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第4张图片

插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第5张图片

假设 线程2 在执行到Entry next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b

线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点

第一步,移动节点a

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第6张图片

第二步,移动节点b

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第7张图片

注意,这里的顺序是反过来的,继续移动节点c

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第8张图片

这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable线程2 开始执行,这时内部的引用关系如下:

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第9张图片

这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。

Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;

执行一次循环之后的引用关系如下图

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第10张图片

执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第11张图片

变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:

1、执行完Entry next = e.next;,目前节点a没有next,所以变量next指向null;

2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;

3、newTable[i] = e 把节点a放到了数组i位置;

4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;

所以最终的引用关系是这样的:

什么?你还因为线程安全问题回去等通知吗?看完这篇文章你再回答不出来来找我!_第12张图片

节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就会查询该链表然后遍历查询,会产生死循环。

另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。

总结

曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashMap。

其实这个问题已经在java1.8版本被修复了,只在1.7版本之前存在这个问题。

大致原因是在HashMap扩容的时候链表采用了头插法会使链表反序,两个线程同时扩容的话,在某种场景下会出现循环链表导致死循环。

java1.8修复的方法是将头插法改成了尾插法,避免了这个死循环问题。可以看一下resize的代码,很容易理解。

那么java1.8就不会出现死循环问题了吗?不是的,在多线程操作红黑树的时候依然有可能会导致死循环,不过这个地方的具体原因没有找到详细文章说明,待证明。

所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。

HashTable为啥是线程安全的

都知道,HashTable是线程安全的,它是给所有方法增加了synchronized。

这样可以达到线程安全吗?答案是可以的。
这种方法的原理是什么?原理是synchronized都需要获取当前实例的锁,static synchronized都需要获取当前类的锁。
synchronized和static synchronized修饰的两个方法之间是同步的吗?不是,因为需要获取的锁不一样。如下代码所示,methodA和methodD是同步的,methodB和methodC是同步的,断点调试会发现当一个线程获取到锁之后,进入RUNNING状态执行后续代码。另一个线程在尝试获取锁的时候会进入到MONITOR状态等待锁。

package com.fastech.interview;

import java.util.concurrent.CountDownLatch;

public class Lock {

    public static synchronized void methodA() {
        System.out.println("aaa");
    }

    public synchronized void methodB() {
        System.out.println("bbb");
    }

    public void methodC() {
        synchronized (this) {
            System.out.println("ccc");
        }
    }

    public void methodD() {
        synchronized (Lock.class) {
            System.out.println("ddd");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Lock lock = new Lock();
        new Thread(Lock::methodA).start();
        new Thread(lock::methodB).start();
        new Thread(lock::methodC).start();
        new Thread(lock::methodD).start();

        CountDownLatch countDownLatch = new CountDownLatch(1);
        countDownLatch.await();
    }

}

HashTable的实现有什么问题?问题很明显,加锁的粒度太粗,我们如果只是为了线程安全,只需要在产生竞态条件的地方加锁就可以了,不需要全部代码加锁,会很影响性能。

ConcurrentHashMap是如何实现线程安全的

ConcurrentHashMap是如何实现线程安全的

我们知道,多线程下一定要用ConcurrentHashMap。但是它是怎么做到线程安全的呢?这个还是很有意思的。

有一篇ConcurrentHashMap是如何实现线程安全的写的很好:

ConcurrentHashMap是如何实现线程安全的

还有看的一个公众号的文章写的很好,记录一下

死磕 java集合之ConcurrentHashMap源码分析(一)
死磕 java集合之ConcurrentHashMap源码分析(二)
死磕 java集合之ConcurrentHashMap源码分析(三)

ConcurrentHashMap一定是线程安全的吗?

首先,是的。如果不是,是因为你对ConcurrentHashMap的线程安全有误解。看下面的例子,这段代码不是线程安全的,最终输出的结果未必是2000。

但这不能说明ConcurrentHashMap不是线程安全的,只能说明下面这段代码不是线程安全的

线程安全是需要考虑一个代码范围或者逻辑范围的,在这个范围内如果程序对多线程下各个环节的执行顺序敏感的话,就存在竞态条件,存在竞态条件的代码不做同步的话,就会出现线程不安全的情况。

所以,ConcurrentHashMap提供的方法是线程安全的,但是先get数据、进行计算、然后再put数据这种逻辑是线程不安全的。

package com.fastech.interview;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class TestConcurrentHashMap {

    static final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public static void increment(String key) {
        map.put(key, map.getOrDefault(key, 0) + 1);
    }

    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(2);
        new Thread(() -> {
            try {
                for (int i = 0; i < 1000; i++) {
                    increment("test");
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        }).start();

        new Thread(() -> {
            try {
                for (int i = 0; i < 1000; i++) {
                    increment("test");
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        }).start();

        try {
            latch.await();
            System.out.println(map.get("test"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

参考文章地址

https://blog.csdn.net/qq_58710208/article/details/123946843

https://blog.csdn.net/yuiop123455/article/details/108273951

https://blog.csdn.net/u014454538/article/details/98515807

https://blog.csdn.net/litterfrog/article/details/76862435

https://juejin.im/post/5a66a08d5188253dc3321da0

https://blog.csdn.net/lijianqingfeng/article/details/118462805

你可能感兴趣的:(多线程,java)