多线程(synchronized&ConcurentHashMap)

前言

上一次我们的多线程衍生到了的概念,由于在编写程序的过程涉及了部分非原子操作,这些操作会让程序在多线程的模型之下运行起来非常危险。而我们又如何使用相应的措施去避免危险呢。

  • synchronized三种用法
  • ConcurrentHashMap的线程同步机制
  • 知识拓展

Synchronize

这里我们设置一个全局变量,并使用平常的自增方法进行自增并输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14

private static int i = 0;

public static void main(String[] args) {

for (int j = 0; j < 1000; j++) {
main.add();
}
}

private void add() {
i++;
System.out.println(i);
}

编写起来非常平铺直叙的代码,输出也是很正常的1,2,3,…,1000

可我们使用多线程来加速完成这个任务↓

1
2

main.add(); -> new Thread(Main::add).start();

输出就出现了错误,并没有让i自增到1000。而且每一次重新运行程序结果都会不一样(这是非常危险的)。

线程的变量的共享几乎是所有多线程的坑的来源!

而这里,我们就可以使用锁的概念,某个线程要想执行相应代码,就必须要拿到对应的锁,执行完了之后,锁再次提供给其他线程争抢,如此反复,保证了保证了某段代码正只被一个线程执行,不被其他线程干扰。这里的锁这是一个概念,我们真正使用的是Java的多线程同步机制关键字synchronized、

synchronized(Object)

先设置一个全局变量Object.
给原先的add方法方法体套上一个synchronized代码块

1
2
3
4
5
6
7
8
9

private static final Object lock = new Object();


private static void add() {
synchronized (lock) {
i++;
System.out.println(i);
}

系统输出再一次回归正常。这是因为我们把一个对象当成了锁,只有拿到了lock的线程才能执行add方法体的代码。

synchronized Method()

直接给静态方法add加上关键字synchronized

1
2
3
4
5

private synchronized static void add () {
i++;
System.out.println(i);
}

系统输出再一次回归正常,这是因为我们把当前所处的Class对象当成了锁。该Class对象存在于JVM,且只有一份(这也是为什么它能成为锁),JVM可以依照这个说明书来创建无数的对象。

synchronized instance.Method()

在实例方法时,我们一样可以使用线程同步块
main方法内创建一个实例main
调用者变成了main
直接给实例方法add加上关键字synchronized

1
2
3
4
5
6
7

public static void main(String[] args) {
Main main = new Main();
for (int j = 0; j < 1000; j++) {
new Thread(main::add).start();
}
}

系统输出再一次回归正常,这是因为我们将当前对象main当成了锁,每一个线程必须争抢到这个锁才能执行相应代码块。

它也有第二种写法。

1
2
3
4
5
6
7

private void add() {
synchronized (this) {
i++;
System.out.println(i);
}
}

这也变相证明了确实是以实例对象为锁实现了线程同步。

ConcurrentHashMap的线程同步机制

我们最常使用的线程安全类就是ConcurrentHashMap

HashMap的不安全,主要发生在put等对HashEntry有直接写操作的地方。

我们讨论多线程问题多半是在写,而不是读。原子性的麻烦远高于可见性
这里就要讲一下同样是基于哈希表实现的HashTable,它一样是线程安全的,但和Vector一样,JDK1时就诞生,却在随后就被弃用了。HashTable是在整个put方法上加上synchronized关键字(就如同上面演示的代码一样),synchronized的机制是在同一时刻只能有一个线程操作,没有争抢到锁的线程继续等待,在线程竞争激烈的情况下,这种方式的效率非常低下。

ConcurrentHashMap.get()

  • 根据key,计算出hashCode;

  • 根据步骤1计算出的hashCode定位segment,如果segment不为null && segment.table也不为null,跳转到步骤3,否则,返回null,该key所对应的value不存在;

  • 根据hashCode定位table中对应的hashEntry,遍历hashEntry,如果key存在,返回key对应的value;

  • 步骤3结束仍未找到key所对应的value,返回null,该key锁对应的value不存在。

    ConcurrentHashMap里所有的value都加上了关键字volatile,volatile保证了三大问题中的可见性,没有synchronized,可谓快了不少(操作是简单的)

ConcurrentHashMap.put()

put

  • 参数校验,value不能为null,为null时抛出NPE;

  • 计算key的hashCode;

  • 定位segment,如果segment不存在,创建新的segment;

  • 调用segment的put方法在对应的segment做插入操作。

segment.put

  • 获取锁,保证put操作的线程安全;

  • 定位到HashEntry数组中具体的HashEntry;

  • 遍历HashEntry链表,假若待插入key已存在:

    需要更新key所对应value(!onlyIfAbsent),更新oldValue -> newValue,跳转到步骤5;

    否则,直接跳转到步骤5;

  • 遍历完HashEntry链表,key不存在,插入HashEntry节点,oldValue = null,跳转到步骤5;

  • 释放锁,返回oldValue。

如果容器有很多把锁,每一把锁控制容器中的一部分数据,那么当多个线程访问容器里的不同部分的数据时,线程之前就不会存在锁的竞争,这样就可以有效的提高并发的访问效率。这也正是ConcurrentHashMap使用的分段锁技术。将ConcurrentHashMap容器的数据分段存储,每一段数据分配一个Segment(锁),当线程占用其中一个Segment时,其他线程可正常访问其他段数据。

扩展

Integer 无法被synchronized锁住!

1
2
3
4
5
6
7
8

//IntegerCache.low = -127
//IntegerCache.high = 128
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

当为Integer赋值数值在-128~127区间时,会从Integer中的一个Integer中获取一个缓存的Integer对象,而超出区间值得时候,每次都会new一个新的Integer对象。对Integer进行递增/递减操作后,其实HashCode已经发生了变化,synchronized自然也不是同一个对象实例。

synchronized关键字不能继承。!

虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。

最后

我们知道,JDK针对每一个结构类都进行了线程安全的概述,明确说明该类到底是线程安全还是非安全的。我们都尽量不要在多线程环境下使用线程不安全的类去编写代码(但还是有人会在多线程下很喜欢使用StringBuilder….)。针对非原子性的操作,我们也要格外注意。Java强大的虚拟机注定了我们虽然编写比其他语言要繁琐一些,可一旦JVM通过,就说明代码没有大问题。但在多线程安全方面,JVM并不能为我们帮太多忙。学习Java线程安全机制和各个常用类的线程同步机制可以让我们更好更安全的去编写多线程代码。