点击上方 蓝字 可以订阅哦
其实创建对象与ConcurrentHashMap之间并没有必然联系,不过很多知识是环环相扣的,这篇文章权当做一次温习吧。
对象和锁
如下 代码, 在 new 一个对象后, j vm会先 检查Student 类 是否已被加 载 ,若未加载则先加载, 否则在堆区创建该 对象 。
Student stu = new Student();
既然对象是分配在堆区,那么对象在堆区的存储结构是怎样的呢,其实主要分为三个部分:
对象头
实例数据
填充数据
这里重点看“对象头”,对象头主要存储信息如下所示。
可以看到,MarkWord标志位存放了对象的锁信息。
我们知道,Java中的任何对象都可以当作锁,那么锁是用来干嘛的呢。在多线程并发中,当多个线程同时访问某个共享资源时容易发生错误,如脏读(线程1读到被线程2修改的数据),因此提供锁机制来 保证线程安全 。
一般说来,线程对资源的访问无非就是读和写,当多个线程对资源只读时,并不会出现线程安全问题,但如果有至少一条线程写资源,就极易会出现问题。
我们大致来看一下java中的锁。
从设计思想来看 ,java锁可分为 悲观锁 和 乐观锁 ,定义如下:
悲观锁:悲观思想,认为读少写多,每次读写资源时都会上锁,其它线程需要一直阻塞直到获取锁,java中典型的悲观锁就是 synchronizied ,AQS框架下的锁会先进行CAS(比较交换,原子操作)获取锁,获取不到才会转换成悲观锁,如 ReentrantLock 。
乐观锁:乐观思想,认为读多写少,在读资源时不上锁,但在写资源时会先判断资源是否被他人修改过,一般通过资源版本号来判断:若资源更新后的版本号与期望版本号一致,则未被修改,否则已被修改。
接着我们再来看下 偏向锁、轻量级锁、重量级 锁 。
偏向锁:运行过程中,若只有一条线程持有锁,没有其它线程与之争夺,那么锁就会偏向于该线程,此锁称为偏向锁,此时只有单条线程,并不需同步操作,能提高程序运行性能。
如果后来有其他线程来争夺锁,那么jvm会将该持有偏向锁的线程挂起,消除它的偏向锁,将锁升级成 轻量级锁 。
轻量级锁:轻量级锁是相对于重量级锁来说的,使用轻量级锁时只需将MarkWord部分字节更新指向线程栈(每个线程都有自己的栈)中的Lock Record,若更新成功,则获取轻量级锁成功,否则说明目前已经有线程获取了轻量级锁,此时发生了竞争,需要升级成 重量级锁 。 轻量级锁也称为乐观锁。
轻量级锁主要有 自旋锁 和 自适应自旋锁 。
自旋锁:如果持有锁的线程能在短时间内释放锁,那么其它线程就不用进入阻塞状态( 阻塞和唤醒线程需要操作系统从用户态切换到核心态,开销较大 ),只需要等待一下(自旋),等到锁被释放再去争夺锁。但是自旋需要占用cpu,一旦自旋时间过长,则会造成cpu浪费,所以需要设置一个最大自旋时间,自旋超过最大时间的线程依然会进入阻塞状态。
自适应自旋锁:自适应意味着线程自旋时间是非固定的,会根据情况动态改变。如线程自旋很少成功获得过锁,那么以后可能会减少自旋时间,甚至忽略自旋,避免浪费cpu资源;对于刚刚自旋获得过锁的线程来说,下一次自旋获得锁的可能性较大,所以会适当增加自旋时间。
重量级锁:由轻量级锁升级而来,也称为 互斥锁 ,当系统检测到是重量级锁后,会将等待获取锁的线程置于阻塞态,不会占用cpu,但是 阻塞和唤醒线程需要操作系统从用户态切换到核心态,开销较大。 重量级锁也叫悲观锁。
这里再补充一点知识。
java中每个对象都有两个池,分别为 锁池 和 等待池 。
锁池:锁被某个线程持有时,其他争夺锁的线程在该线程释放锁之前会进入锁池。
等待池:持有锁的线程在调用对象锁的wait()后会释放锁,并进入等待池。当其他线程调用对象锁的notify()或者notifyAll()后,被唤醒的线程会从等待池进入锁池。
上文说到synchronized是java中的重量级锁,它是一种 独占锁 (线程获取锁后其它线程需要阻塞),除了 synchronized , ReentrantLock 也是独占锁。
synchronized
synchronized 是java中一个用来实现锁机制的关键字,可以用来修饰方法(包括静态方法和非静态方法)和代码块。
修饰静态方法时,锁住的是当前class对象;
修饰非静态方法时,锁住的是当前实例对象;
修饰代码块时,锁住的是()中的对象。
刚才说到 synchronized 是独占锁,意味着在某条线程获取到锁后,其它线程需要阻塞直到锁被释放,这样在任意时刻只有一条线程能进入临界区,显然在多线程环境中,拥有较低的并发性能,且阻塞和唤醒还需要操作系统状态的切换,开销较大,因此 synchronized 是一种典型的 重量级锁 。同时它也是 非公平锁 (多个争夺锁的线程中谁能获取锁是随机的,无论时间先后),所以多线程环境下有可能造成“ 饥饿 ”现象(指某个线程长时间未获得锁)。
synchronized 也有它的好处。我们在代码中使用它时无需手动加锁与释放锁,交由jvm和操作系统来处理即可。而且java新版本已经对 synchronized 做了 优化 ,这个后面会讲到。
ReentrantLock
ReentrantLock是java中的一个类,能实现 可重入锁 (获得锁的线程还能继续重复获取锁,常用于循环体中, synchronized也可重入 ,常用于递归迭代中),它要比 synchronized 灵活,功能也更加丰富。
ReentrantLock 也是 独占锁 ,但和 synchronized 不同, ReentrantLock 需要我们调用lock()、unlock() 手动加锁解锁 ,且加锁的次数和解锁的次数需要一致,否则其它线程可能无法获取锁。
与 synchronized 相比, ReentrantLock 主要有三个特点(区别)。
1. ReentrantLock 能实现 公平锁 (按照线程先来后到的顺序获取锁,可避免饥饿,但性能比非公平锁低),调用无参构造方法或者传入false为非公平锁,传入true为公平锁。
2. ReentrantLock能 实现响应中断。使用 synchronized 时,若线程拿不到锁就会阻塞直到能获取到锁,这种状态无法被中断。但是 ReentrantLock 提供了lockInterruptiably(),可将线程从阻塞状态中断,该方法可用于解决 死锁 问题。
3. ReentrantLock 能实现限时等待。利用其提供的tryLock(),传入时间参数,在指定时间内返回获取锁的结果(true or false),无参则表示立即返回。
在多线程中,线程间常需要进行一些交流,如通知等待和唤醒。
最常见的是Object类的wait()、notify()/notifyAll(),锁对象调用wait(),持有锁的线程会释放锁进入 等待池 ,其它线程调用 notify ()会随机唤醒等待池中的某个线程进入 锁池 ,或调用 notifyAll ()唤醒所有线程。
不同于 synchronized, ReentrantLock结合 Condition 接口来实现通知等待。调用Condition的await()来释放锁,其他线程调用signal()来唤醒线程,类似于wait()、notify()。
这里再说一下wait()和sleep()的区别。
ConcurrentHashMap
HashMap虽然性能好,可它是非线程安全的,在多线程并发下会出现问题,那么有没有解决办法呢?
当然有,可以使用 Collections.synchronizedMap() 将hashmap包装成线程安全的,底层其实使用的就是 synchronized 关键字。但是前面说了,synchronized是重量级锁,独占锁,它会对hashmap的put、get整个都加锁,显然会给并发性能带来影响,类似hashtable。
简单解释一下。
hashmap的底层是哈希表(数组+链表,java1.8后又加上了红黑树),若使用 synchronizedMap() ,那么在线程对哈希表做put/get时,相当于会对整个哈希表加上锁,那么其他线程只能等锁被释放才能争夺锁并操作哈希表,效率较低。
hashtable虽是线程安全的,但其底层也是用 synchronized实现的线程安全,效率也不高。
对此,JUC(java并发包)提供了一种叫做ConcurrentHashMap的线程安全集合类,它使用 分段锁 来实现较高的并发性能。
在java1.7及以下, ConcurrentHashMap 使用的是Segment+ReentrantLock, ReentrantLock 相比于synchronized的优点上文已经介绍过了,我们主要来看下Segment。
我已经在图中附了注释,所以此处便不再做文字说明。
在java1.8后,对 ConcurrentHashMap 做了一些调整,主要有:
链表长度>=8时,链表会转换为红黑树,<=6时又会恢复成链表;
1.7及以前,链表采用的是头插法,1.8后改成了尾插法;
Segment+ReentrantLock改成了 CAS+synchronized 。
主要看第三点,为什么要改成 CAS+synchronized呢?
因为java对 synchronized 进行了优化,这些优化体现在前文所说的 偏向锁、轻量级锁和重量级锁。
这三种锁其实是优化后 synchronized 锁的类别,级别由低到高, 锁只能升级 ,不能降级。每种锁适用于不同场景,整体来看,优化后的 synchronized甚至 比 ReentrantLock 性能要更好。
取消Segment,直接利用table数组单元作为锁,实现了可对每行数据加锁,进一步提高了并发性能。
不过即使有了 ConcurrentHashMap ,也不能忽略HashMap,因为各自适用于不同场景,如 Hash Map 适合于单线程, ConcurrentHashMap 则适合于多线程对map进行操作的环境下。
本篇文章所涉及的内容是面试高频考点,很多地方还可以深挖,请读者自行深入学习。