从创建对象到ConcurrentHashMap
原文始发于微信公众号(BeCoder):从创建对象到ConcurrentHashMap
其实创建对象与ConcurrentHashMap之间并没有必然联系,不过很多知识是环环相扣的,这篇文章权当做一次温习吧。
对象与锁
如下代码,在new一个对象后,jvm会先检查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,因为各自适用于不同场景,如HashMap适合于单线程,ConcurrentHashMap则适合于多线程对map进行操作的环境下。
本篇文章所涉及的内容是面试高频考点,很多地方还可以深挖,请读者自行深入学习。
原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 从创建对象到ConcurrentHashMap
暂无评论