多线程之锁
jmm
Java Memory Model,java内存模型
- 主内存:jvm规定所有的变量都必须在主内存中产生,为了方便理解,可以认为是堆区。
- 工作内存:jvm中每个线程都有自己的工作内存,该内存是线程私有的。为了方便理解,可以认为是虚拟机栈
三大特征
- 可见性:写的时候应立马刷新到主内存,其他线程才能可见。读的时候总是从主内存读最新值
- 原子性:一个操作不能被打断,多线程环境下,一个线程不能被其他线程干扰
- 有序性: 对于代码,我们总是认为是从上而下有序执行。(重排序)
一些概念介绍
cas
- 是一种乐观锁的机制,有三个值:V 内存地址存放的实际值;O 旧值;N 更新后的新值。V和O相同时,N去替换,否则自旋重新尝试。
- 自旋不会阻塞,也就没有用户态核心态的切换,降低了消耗。适用于低并发下保证原子性,并发太高,会有大量的自旋失败。
- CAS的原子性是硬件支撑的,独占cpu。
会产生的问题:
- ABA问题
CAS会检查旧值有没有变化,比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。
解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3A。 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。 - 自旋时间过长
使用CAS时非阻塞同步,如果线程竞争激烈这里自旋时间会很长,对性能是很大的消耗。所以只适合并发较低的情况。
volatile
三大特点
- 可见性:MESI缓存一致性协议来保证的。有volatile关键字的在生成汇编代码时会多出Lock前缀,当它改变时,会立马刷新到主内存,cpu的嗅探机制会立马失效掉其他线程的副本。
- 不保证原子性:如下代码验证,可能同时获得最新值,自增后再写回,导致更新丢失,一般会和cas联合使用。
public class SynchronizedDemo implements Runnable {
private volatile static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
@Override
public void run() {
for (int i = 0; i < 1000000; i++)
count++;
}
}
- 禁止重排序 :防止多线程情况执行出现不一致性。
如下代码:如果存在重排序2可以在1之前执行,2执行完后cpu时间片用完了,另外一个线程执行方法B,因为时volatile写,所以会立马刷新到内存。但此时b仍然是1,进而影响方法B的执行。
volatile int a = 0;
int b = 1;
public void A (){
b = 2; // 1 普通写
a = 1; // 2 volatile写
}
public void B() {
int c = 0;
if (a == 1) // 3 volatile读
c = b; // 4 普通写
}
可重入锁
synchronized和 ReentrantLock都是可重入锁,得到了当前对象的锁后可以在锁中再次进入该对象带有锁的方法。因为是同一把锁。StampedLock不是。
死锁
两个或以上的进程,因为争夺资源而相互等待。jvm里面的调优可以找出来死锁。
原子类
加减库存的操作应具备原子性,同一时间只能有一个线程执行,最直接的办法是次操作加synchronized,但是比较重,有一种原子类,自己具有原子性。其底层就是cas
AtomicIntegerDemo.java
线程安全问题
各个线程会拷贝一份主内存到自己的工作内存,不定时刷新到主内存。
线程安全的问题一般是因为主内存和工作内存数据不一致性和重排序导致的。
局部变量,方法定义参数和异常处理器参数不会在线程间共享。而对象实例、数组都是放在堆内存中,所有线程均可访问到,是可以共享的。
- 数据不一致性:一个线程更新了数据没有及时刷新到主内存。
- 重排序:为了提高性能,编译器和处理器常常会对指令进行重排序
- 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行重排序:处理器采用指令并行技术,将多条指令重叠执行。
- 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
重排序原则:
- as-if-serial:不管怎么重排序单线程程序的执行结果不能被改变。
- happens-before:
1)如果一个操作happens-before另一个操作(代码顺序),那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,但是重排序之后的执行结果不会改变,那么就可以重排序。
as-if-serial保证单线程内程序的执行结果不被改变,happens-before保证多线程的结果不被改变。其目的都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
不过重排序有时也会有问题,比如上面的volatile写。
怎么禁止重排序:通过插入内存屏障实现
- 四种屏障LoadLoad屏障、LoadStore屏障、StoreStore屏障和StoreLoad屏障。
- 在读之前插入一个loadStore屏障,用来禁止把读后面的写重排序到它前面(有序)
synchronized
有锁的方法执行过程:
执行同步代码块首先要先执行monitor enter(jvm指令)指令,退出的时候monitor exit指令。必须先获取对象的监视器monitor,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的。没有获取到监视器的线程进入到BLOCKED状态。
用法
- synchronized关键字加在方法、代码块上,如果是非静态的,则它取得的锁是对象;
- synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
- 一个静态、一个非静态是两个不同的锁
每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码:
synchronized 在方法上,所有这个类的加了 synchronized 的方法,在执行时,会获得一个该类的唯一的同步锁,当这个锁被占用时,其他的加了 synchronized 的方法就必须等待,获得锁的方法调用另外一个有锁的方法不用再取获取,锁的重入性。加在类上,就是所有该类的对象是同一把锁。
synchronized、ReentrantLock都是重量级锁。早期java5之前,效率底下,因为线程的阻塞唤醒需要操作系统介入,在用户态和核心态切换。进而有了1.6的锁优化。
锁优化
锁升级
JDK1.6:synchronize加入了很多优化措施,有了这些措施后它不再直接是重量级锁,刚开始是偏向锁,随着竞争激烈逐步升级。锁可以升级但不能降级。
- 偏向锁(jdk15后慢慢被废弃):
统计发现,大多数情况下,锁总是由同一线程多次获得,很少发生竞争。因此有了偏向锁。
无需操作系统介入,没有用户态 核心态的转化。线程执行的时候,将对象头的偏向锁位标记为1,并存储上自己的线程ID,表示已经获得了锁,并且不会主动释放。下一次线程执行时只需要测试一下对象头里是否存储的是当前线程。如果是直接执行。没有加锁解锁的操作。 - 轻量级锁(自旋锁 自适应自旋锁):核心是cas和自旋
- 上面的测试中,如果对象头里面的线程id不是它,则发生竞争。采用cas来替换掉线程id,竞争成功表示原线程已经不存在,此时仍为偏向锁,只是偏向的线程变了。
- 竞争失败会发生锁升级变成轻量级锁,才能保证每个线程都有机会执行。竞争失败的线程不会阻塞,而是自旋,等待执行的线程执行完后竞争锁。
- 偏向锁不会主动释放,但此时发生了竞争会释放(轻量级锁每次都会释放)。让自旋的线程一起来竞争
自旋锁:该线程等待一段时间(执行一段无意义的循环即可),不让它立即阻塞,看持有锁的线程是否会很快释放锁。如果很快持有锁的线程释放了,那是极好的,不然白白自旋占用资源,默认旋10次。
适应自旋锁:如果自旋完成后刚阻塞就释放锁,就很不智能。因此自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
- 重量级锁
多线程竞争,但是竞争不激烈,使用自旋,不发生阻塞。但是如果竞争的线程太多了,自旋次数过多就会升级到重量级锁。此时没有执行的线程发生阻塞, 出现用户态、核心态的切换了。
jit即时编译器的优化
-
锁消除:jit检测到不存在共享数据竞争,会对这些同步锁进行锁消除。
-
锁粗化:多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
ReentrantLock
用法:
Lock lock=new ReentrantLock();
lock.lock();
lock.unlock();
构造方法传入true是公平锁,默认是非公平锁。公平效率较低,好处是每个线程都能执行
区别
- 和synchronized功效一样的
- 面向对象,synchronized是java内置关键字,ReentrantLock是个java类;
- synchronized会自动释放锁,发生异常也会自动释放,不易死锁。ReentrantLock需要在finally中手工释放锁。
- synchronized 可以给类、方法、代码块加锁;而 ReentrantLock 只能给代码块加锁。
- ReentrantLock 可以获取锁的各种信息,包括有没有成功获取锁,而 synchronized 却无法办到。
ReentrantReadWriteLock
上面两种都时独占锁,都排他。
-
ReentrantReadWriteLock没有写操作的时候,多个线程可同时读(共享锁)。
-
写操作时,不能读,也不能其他线程写(排他锁)。适合读多写少 ReentrantReadWriteLockDemo
特点
-
写太多了,读执行不到,造成锁饥饿
-
锁降级:写锁变成读锁。不能锁升级。获取写锁可以再获取读锁,再释放写锁,就变成读锁了。也就是写后立刻读,它的作用是写了后立马读到自己的数据,防止它释放锁后被其他线程修改。
StampedLock
- 1.8引入邮戳锁,在ReentrantReadWriteLock的基础上,引入乐观读
- 读的过程中也允许写,这样可能产生脏数据,采用乐观锁的机制保证数据正确。读完后还要校验,版本不一致再读一次