java-多线程-volatile,synchronized和lock
文章目录
由于东西很多很乱,就按照Q&A的方式整理以下
进程与线程的区别
- 调度方面:在传统计算机中,进程是调度的基本单位,但是在引入线程之后,线程是调度的基本单位。同一个进程下的线程切换不会引起进程切换,但是不同的就会影响
- 拥有资源方面:进程是拥有资源的单位,线程之间共享内存空间
- 切换方面:进程切换要保存上下文程序计数器很多东西,而线程切换只用保存一些寄存器。开销远小于进程
- 并发性:同一个进程下的线程可以并发,不同进程下的线程也可以并发
volatile关键字
volatile是不稳定的意思,不止用于java,c++也有使用,声明了这个关键字的变量会禁用缓存,每一次都从内存找最新的值,这样就能保证可见性,但是不会保证原子性,例如下面这段代码
1 | public class VolatileAtomicityDemo { |
这里得不到结果的主要原因是,inc++不是一个原子操作,其实是由三个操作并成,先读取inc,再++,最后写回,可能两个线程同时都读到inc为100,这个时候再同时更改写回内存,这样就会达不到预期,所以volatile关键之只能保证可见性,不能保证原子性
如果要保证原子性,可以使用:
- synchronized关键字上锁increase方法
- 既然可以用synchronized方法,那肯定也可以用ReentrantLock,关于这两者的区别后文再说
- ReentrantLock基于CAS和AQS,那肯定用CAS的原子方法也可以解决,后文再说
乐观锁和悲观锁
- 悲观锁:悲观的觉得临界区一定会有人来竞争,所以每次访问的时候一定要上锁,synchronized和ReentrantLock就是比较典型的悲观锁
- 乐观锁:很投机的觉得,这段代码可能不会有别的线程访问,就算有我也不上锁,用其他的方法,例如队列,链表的方式,比较典型的就是CAS(其实我觉得MVCC多少也有点这种思想)
最大的区别就是锁的粒度不一样,悲观锁由于每一次都要上锁,肯定效率没有乐观锁高,反而言之,如果频繁发生冲突,乐观锁的效率也是很低的。总而言之,在读多写少的场景,可以使用乐观锁,写多读少的情况下,使用悲观锁。
CAS和自旋锁
CAS
compare and swap,比较并且交换,主要思想是给出三个数据:要修改的数据的更新值,这个要修改的数据的预期值,要修改数据的实际值。什么意思呢,例如我们要把一个等于1的数据修改成6,我对于这个要修改数据的预期值就是1,当且仅当数据是1的时候,我才会把它修改成6.
这个思想怎么在并发的环境下体现呢?比如这个时候两个线程都要修改这个数据,有一个别的线程抢占先机,由于这个是由volatile修饰的,另一边立即会就看到修改成了另外一个值,不再是预期值1,这个时候cas就会失效,从而保证了并发。
为什么这样就能作为锁呢?
理由很简单,cas保证原子性,为什么他敢理直气壮的说自己是原子的。这就要涉及到一个神奇的类Unsafe,在实现java虚拟机的时候,由于jvm的隔离性,使得java很难接近底层操作系统,Unsafe这个类里面全都是native方法,也就是用的本地方法,对于cas而言,这个底层的方法不是java实现的,是由Unsafe调用dll动态库,也就是c++实现的,cas这条操作可以理解成汇编的一个原子指令,既然是一条指令,那么肯定是可以保证原子性的。
那么问题来了,为什么不直接弄一条锁的原子指令,而这么拐弯抹角的用到这个语句间接完成乐观锁,小编也不知道
自旋锁
我们有了这个CAS操作,到底怎么用到锁上面呢?我们先看一个用CAS的例子:
AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。AtomicInteger是一个操作Integer类型的原子操作类,这里的getAndAddInt可以保证线程安全,获取Object对象在内存偏移量,然后在+i
1 | public final int getAndAddInt (Object var1, Long var2, int var4) { |
主要思想是加的上就加,加不上一直尝试,即使没有加锁,但是也达到了锁的功能。
这种思想称为自旋,我们把这种dowhile的思想运用到类上就是自旋锁,我猜取这个名字的原因是因为一直循环,很像自己一个人在转
1 | public class SpinLock { |
AtomicReference是一个原子类,提供的compareAndSet方法封装了CAS方法,参数中前面是预期值,后面是要修改的值,如果预期值和实际值是一样的,那么就设置为修改值。
上锁过程:owner这里空参数构造,所以我的预期值是空,如果这个时候只有一个线程,那么没有人上锁肯定也是空,所以实际值也是空,满足条件,那么就把空设置为当前线程。如果有两个线程,都同时要上锁,先进来的那个上了锁,下一个compareAndSet方法肯定是假,就会一直while,不会释放cpu
解锁过程:预期值为当前线程,这个线程刚刚上锁了,所以实际值肯定存的也是这个,最后满足条件就把锁设置为null,此时第二个线程终于不用自旋了,就会获取锁。
通过这个过程也看到了,自旋的优点和缺点都很明显,优点是如果时间短的话,可以免去线程的上下文切换时间;但是如果时间长,那就是很消耗cpu的,一直不释放,这个缺点有改进措施,可以用自适应自旋锁,也就说,不再是无限循环,可以设置一个最大循环次数,就不会导致死循环的情况。
ABA问题
CAS存在一个问题,判断实际值和预期值相同,其实不一定就能说明没改过,有可能预期值是A,实际值从A变成了B再变成了A,这个问题其实蛮严重的,如果是变量还好,如果在链表中,判断头节点没变化,不代表接下来的节点没有变化,就会导致完全不一样的结果。
如何避免?
加时间戳,上文用了AtomicReference,这回用AtomicStampedReference,这里使用了时间戳,只有当时间戳相同并且实际值等于预期值,才可以。
Synchronized关键字
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。
关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。
如何使用Synchronized关键字?
- 修饰方法
- 修饰静态方法(锁住当前类):注意修饰静态方法是没有互斥性的,因为静态方法是放在进程中静态代码块那一部分的,所有线程共享,每一个线程都可以访问
- 修饰代码块:
- synchronized(object) 表示进入同步代码库前要获得 给定对象的锁。
- synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
总结:
- synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
- synchronized 关键字加到实例方法上是给对象实例上锁;
- 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。
构造方法可以被Synchronized修饰吗》
不可以,因为本身构造方法就已经是线程安全的了
Synchronized底层原理
基于jvm的monitor,在java编译的时候会给加锁代码加上monitorenter和两个monitorexit,在这中间的就是互斥的,为什么要有两个呢,线程有的时候会抛出异常或者错误,这个时候就会走第二个exit,用两个是为了保证一定能退出。
monitor也是基于c++的,分为三个部分,一个是当前获取锁的对象,还有一个是等待队列,一个是阻塞队列。
上锁成功的标志是monitor的拥有者为当前线程,当释放的时候会唤醒阻塞进程,有一点要说明的是这个过程是非公平,也就是运行不在队列里的新进程也来抢。
Synchronized锁升级原理
这一块暂时还没有细细研究。
有四种锁等级,锁的重量依次递增:
- 不加锁
- 加偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。这也是某种投机行为,乐观锁思想,在很少有进程竞争的时候比较节约资源
- 加轻度锁:也就是自旋锁等,每次操作都是cas,还是乐观锁思想,这个就比偏向锁稍微控制了一点
- 重度锁,使用monitor那种悲观锁
Synchronized发生竞争会依次升级锁,高并发下变成悲观锁
Synchronized和volatile什么区别
- Synchronized修饰方法,静态方法,代码块等,而volatile修饰变量
- volatile是线程同步的轻量实现,具有可见性但是没有原子性,Synchronized可以保证可见性和原子性
- volatile性能肯定比Synchronized好,但是主要解决的是变量的问题,Synchronized解决方法的同步和互斥。
ReentrantLock
可重入锁,实现了Lock接口,相比Synchronized功能更多,实现了轮询,超时,中断,公平锁和非公平锁。
公平和非公平锁主要是Sync里面实现的,而Sync继承自AQS,也就是说ReentrantLock用的就是AQS思想。
AQS
主要数据结构是CLH,最早的CLH是一个虚拟的单向链表,为什么是虚拟的呢,因为甚至连指针都没有,一个CLH节点就包含两个结构,一个是当前线程,另外一个是一个bool变量,用来表示是否获得了锁。
那是怎么运作的呢?
初始化一个尾节点,线程那部分为空,bool部分为true,也就是说可以被上锁,每一个线程都用自旋的方式尝试获得来的时候的尾节点的bool值,那么这个时候来了一个线程,用CAS尝试获取现在的尾节点的bool,为真,那就可以上锁为false,同时自己的bool也为false,表示前面的节点都不能用,所以下一个也不能。
来了一个线程2,现在的尾节点是线程1,他的bool是false,线程2会一直自旋的获取前一个节点的bool值,后面的也是,如果此时线程1好了,那么线程2监听到前面一个变成了true,那就会开始线程2,以此类推。
优点很明显:如果时间短,那么获取和释放锁的开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。还有就是公平,先进先出
这样的CLH有什么缺点呢,首先是都是自旋,如果时间短还好,长了那肯定浪费资源。功能单一,不能支持ReentrantLock的那些接口功能。
AQS对于CLH的改造
针对自旋,AQS都改造成了阻塞,还是不能用自旋。针对第二个功能单一的缺点,使用了双向链表和一个全局变量state(其实我觉得之前那种看上一个节点情况的机制还蛮好的,这不是又重新变成信号量那种机制了)
state属性表示资源的状态,为0表示可以抢了,为1表示不能,为1时候来的线程进阻塞队列
- 公平与非公平,公平就是直接每次都唤醒队列头的,非公平就是来的也一起竞争,但是可能会导致饥饿
- 可重入:一个线程多次进入可以state自增,如果重入两次那么state就是2,但是要保证加锁几次就解锁几次,保证最终还是0
ReentrantLock和synchronized区别
- 首先两者都是悲观锁,也都是可重入的,synchronized是一个关键字,基于jvm实现,ReentrantLock是一个是实现类,实现Lock接口,使用的时候需要声明。
- ReentrantLock功能比较多,实现了可中断(中断也就说如果得不到锁就去干别的事情了,synchronized不可中断),可超时(ReentrantLock实现了,如果超时多少就不去争抢锁了,syn实现不了),锁可以绑定多个条件(Condition类,就相当于不同的信号量,可以控制一部分Condition下的进程等待,另一部分的执行)
- 没有竞争的时候synchronized优化很多,锁没有升级的时候性能很好;重度的时候Lock的性能比较好,因为底层还是用了cas的一部分乐观机制,相比synchronized的monitor还是好一点