Java并发机制的底层实现(网课整理)

拼搏现实的明天。 2023-07-24 11:46 70阅读 0赞

文章目录

  • 并发编程的挑战
    • 上下文切换
    • 多线程一定是快的吗?
  • synchronized的实现原理及应用
    • Monitor对象
    • Monitor与Synchronized
    • Java对象头
    • 锁的升级变化
      • 1.偏向锁
      • 2.轻量级锁
      • 3.锁的优缺点对比
  • volatile原理及应用
    • volatile定义与实现原理
  • 线程不安全类
    • StringBuilder
    • SimpleDataFormat
    • ArrayList
    • HashMap

并发编程的挑战

上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现该机制。 时间片是CPU分配给各个线程的时间,由于时间片非常短,CPU通过不停的切换线程执行,让我们觉得多个线程是同时执行的,时间片一般为几十毫秒(ms)

含义:任务从保存到再加载的过程就是一次上下文切换。

多线程一定是快的吗?

如下代码:

  1. /** * 并发测试 */
  2. public class ConcurrencyTest {
  3. private static final long count=100001;
  4. public static void main(String[] args) throws InterruptedException{
  5. // 并行操作
  6. concurrency();
  7. // 串行操作
  8. serial();
  9. }
  10. private static void concurrency() throws InterruptedException{
  11. long start=System.currentTimeMillis();
  12. Thread thread=new Thread(new Runnable() {
  13. @Override
  14. public void run() {
  15. int a=0;
  16. for (long i=0;i<count;i++){
  17. a+=5;
  18. }
  19. }
  20. });
  21. thread.start();
  22. int b=0;
  23. for(long i=0;i<count;i++){
  24. b‐‐;
  25. }
  26. thread.join();
  27. long time=System.currentTimeMillis()‐start;
  28. System.out.println("concurrency :"+time+"ms, b="+b);
  29. }
  30. private static void serial(){
  31. long start= System.currentTimeMillis();
  32. int a=0;
  33. for(long i=0;i<count;i++){
  34. a+=5;
  35. }
  36. int b=0;
  37. for(long i=0;i<count;i++){
  38. b‐‐;
  39. }
  40. long time=System.currentTimeMillis()‐start;
  41. System.out.println("serial :"+time+"ms, b="+b+", a="+a);
  42. }
  43. }

测试结果:










































循环次数 串行执行耗时/ms 并发执行耗时/ms 并发比串行快多少
1亿 150ms 81ms 约一倍
1千万 20ms 20ms 差不多
1百万 10ms 10ms 差不多
10万 10ms 20ms 慢一倍
1万 0 10ms

可知,在执行越复杂的操作时并发执行越快,而对于用时比较小的操作,因为创建线程也需要一定的性能开销,反而是串行更有效率。

synchronized的实现原理及应用

synchronized实现同步的基础:Java中的每一个对象都可以作为锁。 有三种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchronized括号里配置的对象

Monitor对象

每个对象都有一个Monitor对象相关联,Monitor对象中记录了持有锁的线程信息、等待队列等。Monitor对象包含以下三个字段:

  • _owner 记录当前持有锁的线程
  • _EntryList 是一个队列,记录所有阻塞等待锁的线程
  • _WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程

当线程持有锁的时候,线程id等信息会拷贝进owner字段,其余线程会进入阻塞队列entrylist,当持有锁的线程执行wait方法,会立即释放锁进入waitset,当线程释放锁的时候,owner会被置空,公平锁条件下,entrylist中的线程会竞争锁,竞争成功的线程id会写入owner,其余线程继续在 entrylist 中等待。

Monitor与Synchronized

从JVM规范中可以看到Synchronized在JVM里的实现原理。JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现细节略有不同。

  • 代码块同步是使用monitorenter和monitorexit指令实现的
  • 方法同步是使用另一个方法实现的,JVM会将方法设置 ACC_SYNCHRONIZED 标志,调用的时候 JVM 根据这个标志判断是否是同步方法。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。 JVM保证每个monitorenter必须有对应的monitorexit与之配对。 任何对象都有一个 monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。 线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获取对象的锁。

Java对象头

synchronized用的锁是存在Java对象头里的。

  • 对象是数组类型,虚拟机用3个字宽(Word)存储对象头
  • 非数组类型,则用2字宽存储对象头,在32位虚拟机中,1字宽等于4字节,即32bit

Java对象头的长度


























长度 内容 说明
32/64bit Mark Word 存储对象的hashcode、分代年龄和锁标记位
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(若当前对象是数组)

Java对象头存储结构(32位JVM的Mark Word的默认存储结构)




















锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志 位
无锁状态 对象的hashCode 对象分代年龄 0 01

Mark Word的状态变化

  • 轻量级锁:30bit(指向栈中锁记录的指针) + 2bit(锁标志位 00)
  • 重量级锁:30bit(指向互斥量的指针)+ 2bit(锁标志位 10)
  • GC标记:30bit(空)+ 2bit(锁标志位 11)
  • 偏向锁:23bit(线程ID)+ 2bit(Epoch)+ 4bit(对象分代年龄)+ 1bit(是否偏向锁 1)+ 2bit(锁标志位 01)

64位虚拟机下,Mark Word是64bit大小的

  • 无锁:25bit(unused)+ 31bit(hashCode)+ 1bit(cms_free)+ 4bit(分代年龄)+ 1bit(偏向锁 0)+ 2bit(锁标志位 01)
  • 偏向锁:54bit(线程ID)+ 2bit(Epoch)+ 1bit(cms_free)+ 4bit(分代年龄)+ 1bit(偏 向锁 1)+ 2bit(锁标志位 01)

锁的升级变化

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁” 锁一共4种状态,级别从低到高依次为:无锁状态、偏向锁、轻量级锁、重量级锁。锁状态随着竞争升级,注意:锁可以升级但不能降级。 目的是为了提高获得锁和释放锁的效率。

1.偏向锁

  • 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得
  • 为了降低线程获得锁的代价,引入了偏向锁
  • 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID
  • 不需要CAS操作来加锁和解锁,只简单校验对象头Mark Word里是否存储着指向当前线程的偏向锁
  • 若校验失败,则需再校验Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁)
  • 若没有设置,则使用CAS竞争锁;若设置了,尝试使用CAS将对象头的偏向锁指向当前线程

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制。当发生锁竞争时,偏向锁会变为轻量级锁,这时需要先将偏向锁进行锁撤销,这一步骤也会消耗不少的性能,轻量级锁的Mark Word中,lock标志位为00,其余内容被替换为一个指针,指向了栈里面的锁记录。

锁撤销的过程如下:

  • 在一个安全点停止拥有锁的线程。
  • 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Markword,使其变成无锁状态。
  • 唤醒当前线程,将当前锁升级成轻量级锁。

所以,如果某些同步代码块大多数情况下都是有两个及以上的线程竞争的话,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭。

关闭偏向锁

  • 偏向锁在Java 6和Java 7是默认启用的,但存在几秒钟延迟,可通过设置JVM参数来关闭延迟:- XX:BiasedLockingStartupDelay=0
  • 若确定应用中所有的锁通常情况下处于竞争状态,可关闭偏向锁:-XX:- UseBiasedLocking=false,则默认进入轻量级锁状态

2.轻量级锁

轻量级锁加锁 自旋

轻量级锁的加锁步骤:

  • 线程在自己的栈桢中创建锁记录LockRecord。
  • 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  • 将锁记录中的Owner指针指向锁对象。
  • 将锁对象的对象头的MarkWord替换为指向锁记录的指针。

轻量级锁主要有两种:自旋锁自适应自旋锁。自旋锁会导致空耗CPU且很可能锁不公平;自适应是指根据上一次该线程是否成功或者多久获取过该锁设置旋转次数,若上次失败很可能直接进入重量级锁

轻量级锁解锁 锁膨胀

  • 如果竞争线程增多,锁继续膨胀,变为重量级锁,也是互斥锁,即synchronized,其lock标志位为10,Mark Word其余内容被替换为一个指向对象监视器Monitor的指针。
  • 特殊的是,如果此对象已经被GC标记过,lock会变为11,不含其余内容。

3.锁的优缺点对比






























优点 缺点 试用场景
偏 向 锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外锁撤销的消耗 适用于只有一个线程访问同步块场景
轻 量 级 锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重 量 级 锁 线程不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行时间较长

volatile原理及应用

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  • 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 2)禁止进行指令重排序。

volatile可以看做是轻量级synchronized,它在多处理器开发中保证了共享变量的“可见性”。

  • 可见性:意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值
  • 使用恰当,比synchronized使用和执行成本更低,因为它不会引起线程上下文的切换和调度

volatile定义与实现原理

Java语言规范第3版对volatile定义如下:

  • Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

与其实现原理相关的CPU术语及说明:














































术 语 单词 术语描述
内 存 屏 障 memory barriers 一组处理器指令,用于实现对内存操作的顺序限制
缓 存 行 cache line CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,CPU需要执行几百次CPU指令
原 子 操 作 atomic operations 不可中断的一个或一系列操作
缓 存 行 填 充 cache line fill 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3或所有)
缓 存 命 中 cache hit 若进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写 命 中 write hit 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中
写 缺 失 write misses the cache 一个有效的缓存行被写入到不存在的内存区域

volatile如何保证可见性?

  1. instance = new Singleton(); //instance 是 volatile 变量

JIT编译器转成汇编代码:

  1. 0x01a3de1d: movb $0x0,0x1104800(%esi);
  2. 0x01a3de24: lock addl $0x0,(%esp);

有volatile变量修饰的共享变量进行写操作时会多出第二行汇编代码,Lock前缀的指令在多核处理器 下会引发两个事:

  • 将当前处理器缓存行的数据写回到系统内存
  • 该写回到内存的操作会使在其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存数据读到内部缓存(L1,L2或 其他)后再操作。

volatile的两条实现原则:

  • Lock前缀指令会引起处理器缓存回写到内存
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

线程不安全类

  • 线程安全: 指多个线程在执行同一段代码的时候采用加锁机制,使每次的执行结果和单线程执行的结果都是一样的,不存在执行程序时出现意外结果。
  • 线程不安全: 是指不提供加锁机制保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。

StringBuilder

面试官:StringBuilder和StringBuffer的区别在哪?
我:StringBuilder不是线程安全的, StringBuffer是线程安全的
面试官:那StringBuilder不安全的点在哪儿?
我:…

StringBuilder和StringBuffer的内部实现跟String类一样,都是通过一个char数组存储字符串的,不同的是String类里面的char数组是final修饰的,是不可变的,而StringBuilder和StringBuffer的char 数组是可变的。 先看一段代码:

  1. public class StringBuilderDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. StringBuilder stringBuilder = new StringBuilder();
  4. for (int i = 0; i < 10; i++){
  5. new Thread(new Runnable() {
  6. @Override
  7. public void run() {
  8. for (int j = 0; j < 1000; j++){
  9. stringBuilder.append("a");
  10. }
  11. }
  12. }).start();
  13. }
  14. Thread.sleep(100);
  15. System.out.println(stringBuilder.length());
  16. }
  17. }

这段代码创建了10个线程,每个线程循环1000次往StringBuilder对象里面append字符。正常情况下代码应该输出10000,但是实际运行会输出什么呢? 执行结果:

  1. Exception in thread "Thread‐6" java.lang.ArrayIndexOutOfBoundsException
  2. at java.lang.System.arraycopy(Native Method)
  3. at java.lang.String.getChars(String.java:826)
  4. at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:449)
  5. at java.lang.StringBuilder.append(StringBuilder.java:136)
  6. at
  7. com.kudu.leetcode.test.concurrent.StringBuilderDemo$1.run(StringBuilderDemo.java:17)
  8. at java.lang.Thread.run(Thread.java:748)
  9. 8196
  10. Process finished with exit code 0

输出了“8196”,小于预期的10000,并且还抛出了一个ArrayIndexOutOfBoundsException异常。

为什么输出值跟预期值不一样

查看StringBuilder的两个成员变量(这两个成员变量实际上是定义在AbstractStringBuilder里面 的,StringBuilder和StringBuffer都继承了AbstractStringBuilder)

AbstractStringBuilder里有如下两个变量:

  1. // 存储字符串的具体内容
  2. char[] value;
  3. // 已经使用的的字符数组的数量
  4. int count;

再看StringBuilder的append()方法:

  1. @Override
  2. public StringBuilder append(String str) {
  3. super.append(str);
  4. return this;
  5. }

StringBuilder的append()方法调用的父类AbstractStringBuilder的append()方法:

  1. public AbstractStringBuilder append(String str) {
  2. if (str == null)
  3. return appendNull();
  4. int len = str.length();
  5. ensureCapacityInternal(count + len);
  6. str.getChars(0, len, value, count);
  7. count += len;
  8. return this;
  9. }

count += len不是一个原子操作。假设这个时候count值为10,len值为1,两个线程同时执行到了第七行,拿到的count值都是10,执行完加法运算后将结果赋值给count,所以两个线程执行完后count值为11,而不是12。这就是为什么测试代码输出的值要比10000小的原因。

为什么会抛出ArrayIndexOutOfBoundsException异常

AbstractStringBuilder的append()方法源码的第五行,ensureCapacityInternal()方法是检查StringBuilder对象的原char数组的容量能不能盛下新的字符串,如果盛不下就用newCapacity()对char数组进行扩容。

  1. private void ensureCapacityInternal(int minimumCapacity) {
  2. // overflow‐conscious code
  3. if (minimumCapacity value.length > 0) {
  4. value = Arrays.copyOf(value,
  5. newCapacity(minimumCapacity));
  6. }
  7. }

扩容的逻辑就是new一个新的char数组,新的char数组的容量是原来char数组的两倍再加2,再通过 System.arrayCopy() 函数将原数组的内容复制到新数组,最后将指针指向新的char数组。

  1. private int newCapacity(int minCapacity) {
  2. // overflow‐conscious code
  3. int newCapacity = (value.length << 1) + 2;
  4. if (newCapacity minCapacity < 0) {
  5. newCapacity = minCapacity;
  6. }
  7. return (newCapacity <= 0 || MAX_ARRAY_SIZE newCapacity < 0)
  8. ? hugeCapacity(minCapacity)
  9. : newCapacity;
  10. }

Arrys.copyOf()方法:

  1. public static char[] copyOf(char[] original, int newLength) {
  2. char[] copy = new char[newLength];
  3. System.arraycopy(original, 0, copy, 0,
  4. Math.min(original.length, newLength));
  5. return copy;
  6. }

AbstractStringBuilder的append()方法源码的第六行,是将String对象里面char数组里面的内容拷贝到StringBuilder对象的char数组里面,代码如下:

  1. str.getChars(0, len, value, count);

getChars()方法

  1. public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
  2. if (srcBegin < 0) {
  3. throw new StringIndexOutOfBoundsException(srcBegin);
  4. }
  5. if (srcEnd > value.length) {
  6. throw new StringIndexOutOfBoundsException(srcEnd);
  7. }
  8. if (srcBegin > srcEnd) {
  9. throw new StringIndexOutOfBoundsException(srcEnd srcBegin);
  10. }
  11. System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd srcBegin);
  12. }

示例:

  1. 假设现在有两个线程同时执行了StringBuilder的append()方法,两个线程都执行完了第五行的 ensureCapacityInternal()方法,此刻count=5。
  2. 这个时候线程1的cpu时间片用完了,线程2继续执行。线程2执行完整个append()方法后count 变成6了。
  3. 线程1继续执行第六行的str.getChars()方法的时候拿到的count值就是6了,执行char数组拷贝的时候就会抛出ArrayIndexOutOfBoundsException异常。

StringBuffer用什么手段保证线程安全

通过synchronized同步锁实现,如下:

  1. @Override
  2. public synchronized StringBuffer append(Object obj) {
  3. toStringCache = null;
  4. super.append(String.valueOf(obj));
  5. return this;
  6. }

SimpleDataFormat

线程不安全验证:

  1. public class SimpleDateFormatTest {
  2. private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy‐MM‐dd HH:mm:ss");
  3. ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(10, 100, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(1000));
  4. @Test
  5. public void test() {
  6. while (true) {
  7. poolExecutor.execute(new Runnable() {
  8. @Override
  9. public void run() {
  10. String dateString = simpleDateFormat.format(new Date());
  11. try {
  12. Date parseDate = simpleDateFormat.parse(dateString);
  13. String dateString2 = simpleDateFormat.format(parseDate);
  14. System.out.println(dateString.equals(dateString2));
  15. } catch (ParseException e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. });
  20. }
  21. }
  22. }
  23. 输出:
  24. true
  25. false
  26. true
  27. true
  28. false

出现了false,说明线程不安全。

查看源码format方法:

  1. @Override
  2. public StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition pos)
  3. {
  4. pos.beginIndex = pos.endIndex = 0;
  5. return format(date, toAppendTo, pos.getFieldDelegate());
  6. }
  7. // Called from Format after creating a FieldDelegate
  8. private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) {
  9. // Convert input date to time field list
  10. calendar.setTime(date);
  11. boolean useDateFormatSymbols = useDateFormatSymbols();
  12. for (int i = 0; i < compiledPattern.length; ) {
  13. int tag = compiledPattern[i] >>> 8;
  14. int count = compiledPattern[i++] & 0xff;
  15. if (count == 255) {
  16. count = compiledPattern[i++] << 16;
  17. count |= compiledPattern[i++];
  18. }
  19. switch (tag) {
  20. case TAG_QUOTE_ASCII_CHAR:
  21. toAppendTo.append((char)count);
  22. break;
  23. case TAG_QUOTE_CHARS:
  24. toAppendTo.append(compiledPattern, i, count);
  25. i += count;
  26. break;
  27. default:
  28. subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
  29. break;
  30. }
  31. }
  32. return toAppendTo;
  33. }
  34. //calendar 对象
  35. protected Calendar calendar;

可以看到,多个线程之间共享变量calendar,并修改calendar。因此在多线程环境下,当多个线程同时使用相同的SimpleDateFormat对象(如static修饰)的话,如调用format方法时,多个线程会同时调用calender.setTime方法,导致time被别的线程修改,因此线程是不安全的。

此外,parse方法也是线程不安全的,parse方法实际调用的是CalenderBuilder的establish来进行解析,其方法中主要步骤不是原子操作。

解决方案:

  1. 将SimpleDateFormat定义成局部变量
  2. 加一把线程同步锁:synchronized(lock)
  3. 使用ThreadLocal,每个线程都拥有自己的SimpleDateFormat对象副本

    1. public class SimpleDateFormatTest {
    2. ThreadLocal<SimpleDateFormat> local = new ThreadLocal<>();
    3. ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(10, 100, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(1000));
    4. @Test
    5. public void test() {
    6. while (true) {
    7. poolExecutor.execute(new Runnable() {
    8. @Override
    9. public void run() {
    10. SimpleDateFormat simpleDateFormat = local.get();
    11. if (simpleDateFormat == null) {
    12. simpleDateFormat = new SimpleDateFormat("yyyy‐MM‐dd HH:mm:ss");
    13. }
    14. String dateString = simpleDateFormat.format(new Date());
    15. try {
    16. Date parseDate = simpleDateFormat.parse(dateString);
    17. String dateString2 = simpleDateFormat.format(parseDate);
    18. System.out.println(dateString.equals(dateString2));
    19. } catch (ParseException e) {
    20. e.printStackTrace();
    21. } finally {
    22. local.remove();
    23. }
    24. }
    25. });
    26. }
    27. }
    28. }
  4. 使用DateTimeFormatter代替SimpleDateFormat DateTimeFormatter是线程安全的,默认提 供了很多格式化方法,也可以通过ofPattern方法创建自定义格式化方法。
    (1)格式化日期示例:

    1. @Test
    2. public void dateTimeFormatterTest(){
    3. LocalDateTime localDateTime = LocalDateTime.now();
    4. System.out.println(localDateTime);
    5. DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy‐MM‐dd HH:mm:ss");
    6. String strDate = localDateTime.format(dtf);
    7. System.out.println(strDate);
    8. }

    (2)解析日期

    1. @Test
    2. public void parseDateTest() {
    3. DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy‐MM‐dd HH:mm:ss");
    4. LocalDateTime localDateTime = LocalDateTime.parse("2020‐01‐30 15:23:46", dtf);
    5. System.out.println(localDateTime);
    6. }
  5. 使用JDK8全新的日期和时间API

    • LocalDate、LocalTime、LocalDateTime Date如果不格式化,打印出的日期可读性极差,可使用LocalDateTime代替。
    • Instant now = Instant.now();通过这种方式获取的时间戳与北京时间相差8个时区,需要修正为 北京时间

      Instant now = Instant.now().plusMillis(TimeUnit.HOURS.toMillis(8));

ArrayList

先看源码:

  1. public class ArrayList<E> extends AbstractList<E>
  2. implements List<E>, RandomAccess, Cloneable, java.io.Serializable
  3. {
  4. /** * 列表元素集合数组 * 如果新建ArrayList对象时没有指定大小,那么会将EMPTY_ELEMENTDATA赋值给elementData * 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY */
  5. transient Object[] elementData;
  6. /** * 列表大小,elementData中存储的元素个数 * @serial */
  7. private int size;
  8. }

ArrayList的实现主要就是用了一个Object的数组,用来保存所有的元素,以及一个size变量用来保存当前数组中已经添加了多少元素。 再来看add函数源码:

  1. /** * Appends the specified element to the end of this list. * * @param e element to be appended to this list * @return <tt>true</tt> (as specified by {@link Collection#add}) */
  2. public boolean add(E e) {
  3. ensureCapacityInternal(size + 1); // Increments modCount!!
  4. elementData[size++] = e;
  5. return true;
  6. }

由此看到add元素时,实际做了两个大的步骤:

  1. 判断elementData数组容量是否满足需求
  2. 在elementData对应位置上设置值

这样也就出现了第一个导致线程不安全的隐患,在多个线程进行add操作时可能会导致elementData 数组越界。具体逻辑如下:

  1. 列表大小为9,即size=9
  2. 线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
  3. 线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
  4. 线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
  5. 线程B也发现需求大小为10,也可以容纳,返回。
  6. 线程A开始进行设置值操作,elementData[size++] = e 操作。此时size变为10。
  7. 线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常 ArrayIndexOutOfBoundsException.

另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这 步操作也不是一个原子操作,它由如下两步操作构成:

  1. elementData[size] = e;
  2. size = size + 1;

在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:

  1. 列表大小为0,即size=0
  2. 线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
  3. 接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
  4. 线程A开始将size的值增加为1
  5. 线程B开始将size的值增加为2

测试代码:

  1. import java.util.ArrayList;
  2. import java.util.List;
  3. /** * @author binzhang * @date 2020-04-15 */
  4. public class ArrayListTest {
  5. public static void main(String[] args) throws InterruptedException {
  6. final List<Integer> list = new ArrayList<>();
  7. // 线程A 将0‐1000 添加到 list
  8. new Thread(new Runnable() {
  9. @Override
  10. public void run() {
  11. for (int i = 0; i < 1000; i++) {
  12. list.add(i);
  13. try {
  14. Thread.sleep(1);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. }
  20. }).start();
  21. // 线程 B 将 1000‐2000 添加到列表
  22. new Thread(new Runnable() {
  23. @Override
  24. public void run() {
  25. for (int i = 1000; i < 2000; i++) {
  26. list.add(i);
  27. try {
  28. Thread.sleep(1);
  29. } catch (InterruptedException e) {
  30. e.printStackTrace();
  31. }
  32. }
  33. }
  34. }).start();
  35. Thread.sleep(1000);
  36. // 打印所有结果
  37. for (int i = 0; i < list.size(); i++) {
  38. System.out.println("第" + (i + 1) + "个元素为:" + list.get(i));
  39. }
  40. }
  41. }

测试结果:

  1. 1个元素为:0
  2. 2个元素为:1
  3. 3个元素为:1000
  4. 4个元素为:2
  5. 5个元素为:1001
  6. .....
  7. 1550个元素为:1806
  8. 1551个元素为:813
  9. 1552个元素为:814
  10. 1553个元素为:1807
  11. 1554个元素为:815
  12. 1555个元素为:1808
  13. Process finished with exit code 0

HashMap

首先HashMap是线程不安全的,其主要体现:

  1. 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  2. 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

jdk1.7中的HashMap
测试代码:

  1. public class HashMapTest {
  2. public static void main(String[] args) {
  3. HashMapThread thread0 = new HashMapThread();
  4. HashMapThread thread1 = new HashMapThread();
  5. HashMapThread thread2 = new HashMapThread();
  6. HashMapThread thread3 = new HashMapThread();
  7. HashMapThread thread4 = new HashMapThread();
  8. thread0.start();
  9. thread1.start();
  10. thread2.start();
  11. thread3.start();
  12. thread4.start();
  13. }
  14. }
  15. class HashMapThread extends Thread {
  16. private static AtomicInteger ai = new AtomicInteger();
  17. private static Map<Integer, Integer> map = new HashMap<>();
  18. @Override
  19. public void run() {
  20. while (ai.get() < 1000000) {
  21. map.put(ai.get(), ai.get());
  22. ai.incrementAndGet();
  23. }
  24. }
  25. }

多次运行代码后会出现死循环的情况,通过jps和jstack命名查看死循环情况,发现死循环发生在 HashMap的扩容函数中,根源在transfer函数中,jdk1.7中HashMap的transfer函数如下:

  1. void transfer(Entry[] newTable, boolean rehash) {
  2. int newCapacity = newTable.length;
  3. for (Entry<K,V> e : table) {
  4. while(null != e) {
  5. Entry<K,V> next = e.next;
  6. if (rehash) {
  7. e.hash = null == e.key ? 0 : hash(e.key);
  8. }
  9. int i = indexFor(e.hash, newCapacity);
  10. e.next = newTable[i];
  11. newTable[i] = e;
  12. e = next;
  13. }
  14. }
  15. }

在对table进行扩容到newTable后,需要将原来数据转移到newTable中,注意10-12行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。

HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是 rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环, 导致在get时会出现死循环,所以HashMap是线程不安全的。

jdk1.8中HashMap

  1. /** * Implements Map.put and related methods * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */
  2. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  3. boolean evict) {
  4. Node<K,V>[] tab; Node<K,V> p; int n, i;
  5. if ((tab = table) == null || (n = tab.length) == 0)
  6. n = (tab = resize()).length;
  7. if ((p = tab[i = (n - 1) & hash]) == null)
  8. tab[i] = newNode(hash, key, value, null);
  9. else {
  10. Node<K,V> e; K k;
  11. if (p.hash == hash &&
  12. ((k = p.key) == key || (key != null && key.equals(k))))
  13. e = p;
  14. else if (p instanceof TreeNode)
  15. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  16. else {
  17. for (int binCount = 0; ; ++binCount) {
  18. if ((e = p.next) == null) {
  19. p.next = newNode(hash, key, value, null);
  20. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  21. treeifyBin(tab, hash);
  22. break;
  23. }
  24. if (e.hash == hash &&
  25. ((k = e.key) == key || (key != null && key.equals(k))))
  26. break;
  27. p = e;
  28. }
  29. }
  30. if (e != null) { // existing mapping for key
  31. V oldValue = e.value;
  32. if (!onlyIfAbsent || oldValue == null)
  33. e.value = value;
  34. afterNodeAccess(e);
  35. return oldValue;
  36. }
  37. }
  38. ++modCount;
  39. if (++size > threshold)
  40. resize();
  41. afterNodeInsertion(evict);
  42. return null;
  43. }

注意第6行代码,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

总结:

(1)在put的时候,因为该方法不是同步的,假如有两个线程A,B它们的put的key的hash值相同,不论是从头插入还是从尾插入,假如A获取了插入位置为x,但是还未插入,此时B也计算出待插入位置为 x,则不论AB插入的先后顺序肯定有一个会丢失。

(2)在扩容的时候,jdk1.8之前是采用头插法,当两个线程同时检测到hashmap需要扩容,在进行同时扩容的时候有可能会造成链表的循环,主要原因就是,采用头插法,新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题,同时1.8的链表长度如果大于8就会转变成红黑树。

发表评论

表情:
评论列表 (有 0 条评论,70人围观)

还没有评论,来说两句吧...

相关阅读