java实现线程安全的策略有三种
- 互斥同步: 获得锁的线程执行,没有获得锁的阻塞
- 非阻塞同步:不对数据加锁,再对数据进行修改的时候对数据进行检验。判断数据有没有在被其他人使用
- 无同步方案:如果能让一个方法本来就不涉及共享数据,那么他就不需要进行同步,比如说:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
锁
关于锁我们也了解了具体的操作,上周老师也让我们查询了相关的cas 乐观锁问题。在此我发现了锁有很多的分类,所以这里对分类进行了一个大致的整合与学习。在之前,简单的了解一下锁的一些名词
- 锁膨胀:是锁的等级进行升级的一个形容
- 锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除,就是说,虽然我在一个代码里面加了锁,但是虚拟机在执行的时候发现这个地方根本不可能会和别人共享数据,那么虚拟机会无视掉这个锁
- 锁粗化:如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的。虚拟机会放大锁的作用区间到整个对象。
关于锁
synchronization与Lock接口的相关实现类是我们java中的加锁方式
synchronized是Java语言内置的关键字,而Lock是一个接口,这个接口的实现类在代码层面实现了锁的功能,AbstractQueuedSynchronizer。
下面的图是一个ReentrantLock、ReadLock、WriteLock 是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”和“写锁”。
乐观锁与悲观锁
锁的一种宏观分类方式是悲观锁和乐观锁。它指的是java在并发情况下的两种不同策略
悲观锁:每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放。
乐观锁:很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁!,如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功
CAS
CAS:Compare-and-Swap
- 比较:读取到了一个值A,在将其更新为B之前,检查原值是否仍为A
- 设置:如果是,将A更新为B,结束。如果不是,则什么都不做。
上述的操作是原子性的
//代码的简单模拟:
int i = 1;
boolean flag =true ;
while(flag){
int oldI = i ;//保存原始数据
int newI = doSomething(i) //对i进行某些操作
if (i == oldI) { // 比较
i = newI; // 设置
flag = false; // 结束
} else {
// 啥也不干,循环重试
}
}
//简单的乐观锁实现,允许多个线程同时读取,但是只有一个线程可以成功更新数据,并导致其他要更新数据的线程回滚重试。
//注意代码没有保证原子性 所以只是一个见到那模拟
自旋锁与自适应自旋
在使用诸如synchronized这类锁进行同步的时候,对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁就是线程自己在执行一个忙循环,类似执行一个空的死循环。
- 自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。自旋次数的默认值是十次。
- JDK 6引入了优化,引入了自适应的自旋。自旋的时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。
- 如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。
偏向锁 → 轻量级锁 → 重量级锁
为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Jdk 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级
对象头MarkWord
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age) 等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。
另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
偏向锁
在使用多线程的时候,大多数时候会出现这种情况:锁总是由同一线程多次获得,而不是多个线程之间竞争。
为了让线程获得锁的代价更低而引入了偏向锁。
因为这个锁偏向于第一个获取到他的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志 位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作 等)。
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位
为“01”)或轻量级锁定(标志位为“00”)的状态,
-
初始化过程:
- 当锁对象第一次被线程获取的时候,虚拟机会将对象头中的锁标志位置为 “01”(偏向模式)
- 同时,使用CAS操作,把获取到这个锁的线程的ID记录在对象的Mark Word中,
- 如果 CAS成功,持有偏向锁的线程每次进入这个锁相关的同步块时,虚拟机可以不进行任何同步操作
-
撤销过程:
首先暂停拥有偏向锁的线程
然后检查持有偏向锁的线程是否活着
不活跃,将对象头设置成无锁状态 (标志位"01",但不可偏向)
-
活
- CAS成功,重新偏向,更改线程ID
- 失败,恢复成无锁状态,或者变成轻量级锁定状态。
轻量级锁
轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
在前面介绍了自旋锁,其实轻量级锁就是使用的自旋锁。
它是具体是指:多个线程竞争锁时,没有锁的的线程自旋进行等待锁
- 加锁
- 线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录(下图的LockRecord)的空间,并将对象头中的 Mark Word复制到锁记录中,官方称为 Displaced Mark Word
然后,虚拟机将使用CAS操作,将对象的 Mark Word更新为指向锁记录的指针 - 如果这个操作成功,那么该线程就有了该对象的锁,并且对象的 Mark Word的锁标志位置为 “00”,表示该对象处于轻量级锁定状态
- 如果更新失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁
- 线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录(下图的LockRecord)的空间,并将对象头中的 Mark Word复制到锁记录中,官方称为 Displaced Mark Word
- 解锁
- 用CAS操作将 Displaced Mark Word 替换回到对象头
- 如果成功,则说明没有发生竞争
- 失败,则表示当前锁存在竞争,锁就会膨胀成重量级锁
- 释放锁,并且唤醒等待的线程
- 用CAS操作将 Displaced Mark Word 替换回到对象头
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
偏向锁,轻量级锁,重量级锁之间的转换
当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置
当一个对象已经计算过一致性哈希码(调用Object::hashCode()或者System::identityHashCode(Object)方法)后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要 计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁 状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。
对比
锁 | 优点 | 缺点 | 适合场景 |
---|---|---|---|
偏向锁 | 加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间 同步块执行速度非常块 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量 同步块执行时间较长 |
公平锁与非公平锁
公平锁:新进程发出请求,如果此时一个线程正持有锁,或有其他线程正在等待队列中等待这个锁,那么新的线程将被放入到队列中被挂起
非公平锁:如果此时一个线程正持有锁,新的线程将被放入到队列中被挂起,但如果发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。
可重入锁
简单来说,可重入锁就是指如果一个线程调用了一把锁,在这个线程内部继续可以继续调用这一把锁而不会被阻塞。
共享锁与排他锁
允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。如:ReentrantLock
排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。如:CountDownLatch