一、悲观锁和乐观锁概念

悲观锁和乐观锁是一种广义的锁概念,Java中没有哪个Lock实现类就叫PessimisticLock或OptimisticLock,而是在数据并发情况下的两种不同处理策略。

针对同一个数据并发操作,悲观锁认为自己在使用数据时,一定有其它的线程操作数据,因此获取数据前先加锁确保数据使用过程中不会被其它线程修改;乐观锁则认为自己在使用数据的时候不会被其它线程修改。基于两者的不同点我们不难发现:

(1)悲观锁适用于写操作较多的场景。

(2)乐观锁适用于读操作较多的场景。

二、乐观锁的一种实现方案

乐观锁通常采用了无锁编程方法,基于CAS(Compare And Swap)算法实现,下面重点介绍一下该算法:

先看一个例子,假设有100个线程同时并发,且每个线程累加1000次,那么结果很容易算出时100,000,实现代码如下:

 1 public class Test {
2 private static int sum = 0;
3
4 public static void main(String[] args) throws InterruptedException {
5 final CountDownLatch latch = new CountDownLatch(100);
6 for (int i = 0; i < 100; i++) {
7 new Thread(() -> {
8 for (int j = 0; j < 1000; j++) {
9 sum += 1;
10 }
11 latch.countDown();
12 }).start();
13 }
14 latch.await();
15 System.out.println(String.format("Sum=%s", sum));
16 }
17 }

很显然,由于资源(sum变量)同步的问题,上述代码运行结果跟我们预期不一样,而且每次结果也不一样。

那么sum变量增加volatile修饰符呢?结果还是有问题,这是因此为sum +=1不是原子语句,很显然我们需要把sum+=1这个语句加锁,那么每次执行结果都一样且跟预期(100,000)相符。

定义一个可重入锁

1 private static Lock lock = new ReentrantLock();

资源加锁

1 lock.lock();
2 sum += 1;
3 lock.unlock();

ReentrantLock是基于悲观锁实现方案,每次加锁、释放锁都涉及到用户态和内核态切换(保存、恢复线程上下文以及线程调度等),因此性能损失较大。那么乐观锁又是如何实现的呢?实现方法如下:

 1 public class Test {
2 private static AtomicInteger sum = new AtomicInteger(0);
3
4 public static void main(String[] args) throws InterruptedException {
5 final CountDownLatch latch = new CountDownLatch(100);
6 for (int i = 0; i < 100; i++) {
7 new Thread(() -> {
8 for (int j = 0; j < 1000; j++) {
9 sum.addAndGet(1);
10 }
11 latch.countDown();
12 }).start();
13 }
14
15 latch.await();
16
17 System.out.println(String.format("Sum=%s", sum.get()));
18 }
19 }

上述这个例子会出现频繁写入,在实际工程中并不一定适合乐观锁,这里主要讲解一下乐观锁实现原理。

AtomicInteger是针对Integer类型的封装,除此之外还包括AtomicLong、AtomicReference等,下面重分析addAndGet这个方法。

addAndGet会调用unsafe.getAndAddInt,第一个参数是AtomaticInteger实例(sum对象);第三个参数是我们传入要累加的值;第二个参数valueOffset是AtomaticInteger中value属性(我们每次累加的结果就是保存在value中)的偏移地址,初始化代码如下:

getAndAddInt实现代码如下:

其中,var5 = this.getIntVolatile(var1, var2),var1是sum对象、var2是value的偏移量地址,getIntVolatile就是根据偏移量地址读取sum对象中存储的value值,即var5=value

compareAndSwapInt(var1, var2, var5, var5 + var4),var1是sum对象,var2是sum对象中value的偏移量地址,var5是之前读取的value值,var5+var4是本次操作期望写入的value新值。写入新值之前会判断最新的value值是否和之前获取的值(var5)相等,相等的话更新新值并返回true;否则直接返回false,不做任何操作。

当写入成功时就会跳出do-while循环,否则会一直重试,注意整个循环体是没有阻塞的,因此也避免了线程上下文切换。

compareAndSwapInt是Java的native方法,并不由Java语言实现,其底层依赖于CPU提供的指令集(比如x86的cmpxchg )保证其操作的原子性。

三、轻量级自旋锁

自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程阻塞状态,自旋锁的好处是避免线程上下文切换,但是坏处也很明显,如果没有获取到锁时会不停的循环监测,这个循环监测过程就是自旋操作。

本节还是基于CAS操作实现一个简单的自旋锁,代码如下:

 1 public class SimpleSpinLock {
2
3 private AtomicReference<Thread> atomicReference = new AtomicReference<>();
4
5 public void lock() {
6 Thread currentThread = Thread.currentThread();
7 //没有获取到锁时候,处于自旋过程而不是阻塞状态。
8 while (!atomicReference.compareAndSet(null, currentThread)) {
9 }
10 System.out.println(String.format("Lock success. atomic=%s", atomicReference.get().getName()));
11 }
12
13 public void unLock() {
14 Thread currentThread = Thread.currentThread();
15 if (atomicReference.compareAndSet(currentThread, null)) {
16 System.out.println(String.format("Unlock success. atomic=%s", currentThread.getName()));
17 } else {
18 System.out.println(String.format("Unlock failure. atomic=%s", currentThread.getName()));
19 }
20 }
21 }
22
23 public class Test {
24 private static int sum = 0;
25 private static SimpleSpinLock lock = new SimpleSpinLock();
26
27 public static void main(String[] args) throws InterruptedException {
28 final CountDownLatch latch = new CountDownLatch(100);
29 for (int i = 0; i < 100; i++) {
30 Thread thread = new Thread(() -> {
31 for (int j = 0; j < 1000; j++) {
32 lock.lock();
33 sum++;
34 lock.unLock();
35 }
36 latch.countDown();
37 });
38 thread.setName(String.format("CountThread-%s", i));
39 thread.start();
40 }
41
42 latch.await();
43
44 System.out.println(String.format("Sum=%d", sum));
45 }
46 }

上述SimpleSpinLock是一个最简的实现方案,假如某个线程一直申请不到锁,那么就会一直处于空转自旋状态,这个使用我们通常会设置一个自旋次数,超过这个次数(比如10次)时膨胀成重量级的互斥锁,减少CPU空转消耗。

那么本节的最后一个问题,在实际工程使用中如何定义自旋次数?

JDK1.6引入了自适应自旋锁,所谓自适应自旋锁,就意味着自旋的次数不再是固定的,具体规则如下:

自旋次数通常由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。比如线程T1自旋10次成功,那么等到下一个线程T2自旋时,也会默认认为T2自旋10次。

如果T2自旋了5次就成功了,那么此时这个自旋次数就会缩减到5次。

四、偏向锁

偏向锁是JDK 1.6提出的一种锁优化方式。其核心思想是如果资源没有竞争,就取消之前已经取得锁得线程同步操作。具体实现方案如下:

  • 某一线程第一次获取锁时便进入偏向模式,当该线程再次请求这个锁时,无需再进行相关得同步操作(不需要CAS计算)。
  • 如果在此期间有其它线程进行了锁请求,则锁退出偏向模式。
  • 当锁处于偏向模式时,虚拟机中的Mark Word会记录获得锁得线程ID。

最后我们看一下Mark Word在哪里:

五、再谈synchronized

看完偏向锁实现方案,你是否和我一样有这样的疑问?没有资源竞争情况偏向锁才有用,一旦有有竞争偏向锁就失效了,那么在没有资源竞争的情况下,我为什么要加锁呢?好吧,本节的最后我将回答这个问题。

synchronized在JDK 1.5的早期版本中使用重量级锁(通过Monitor关联到操作系统的互斥锁),效率很低,因此JDK 1.6做了大幅度优化,整个资源同步过程支持锁升级(无锁、偏向锁、轻量级锁、重量级锁),且升级后不能降级。这一升级过程都伴随着Mark Word存储内容的改变,Mark Word会根据对象的不同状态存放不同的数据,数据格式如下:

好吧,到这里我们来回答一下开头的那个疑惑,早期的Java版本提供了Vector、HashTable、StringBuffer 等这些线程安全的集合,其内部实现依赖于synchronized实现重量级锁,因此效率低下,但是开发人员使用这些集合时大部分都是在单线程环境下,并不会出现资源竞争的场景,因此在后续优化synchornized时,顺便增加了这个偏向锁在保证可能出现并发的情况下提高的Vector、HashTable执行效率。然而今天我们在写Java代码时,任何一本编码规范都有要求我们优先考虑ArrayList、HashMap、StringBuilder这些非线程安全的集合,那么我们还需要偏向锁吗?O(∩_∩)O

Java基础之(一)——从synchronized优化看Java锁概念的更多相关文章

  1. synchronized优化手段:锁膨胀、锁消除、锁粗化和自适应自旋锁...

    synchronized 在 JDK 1.5 时性能是比较低的,然而在后续的版本中经过各种优化迭代,它的性能也得到了前所未有的提升,上一篇中我们谈到了锁膨胀对 synchronized 性能的提升,然 ...

  2. java基础---->多线程之synchronized(六)

    这里学习一下java多线程中的关于synchronized的用法.我来不及认真地年轻,待明白过来时,只能选择认真地老去. synchronized的简单实例 一. synchronized在方法上的使 ...

  3. Java基础学习总结(68)——有关Java线程方面的面试题

    不管你是新程序员还是老手,你一定在面试中遇到过有关线程的问题.Java 语言一个重要的特点就是内置了对并发的支持,让 Java 大受企业和程序员的欢迎.大多数待遇丰厚的 Java 开发职位都要求开发者 ...

  4. synchronized 优化手段之锁膨胀机制!

    synchronized 在 JDK 1.5 之前性能是比较低的,在那时我们通常会选择使用 Lock 来替代 synchronized.然而这个情况在 JDK 1.6 时就发生了改变,JDK 1.6 ...

  5. Java基础14:离开IDE,使用java和javac构建项目

    更多内容请关注微信公众号[Java技术江湖] 这是一位阿里 Java 工程师的技术小站,作者黄小斜,专注 Java 相关技术:SSM.SpringBoot.MySQL.分布式.中间件.集群.Linux ...

  6. Java基础学习总结(70)——开发Java项目常用的工具汇总

    要想全面了解java开发工具,我们首先需要先了解一下java程序的开发过程,通过这个过程我们能够了解到java开发都需要用到那些工具. 首先我们先了解完整项目开发过程,如图所示: 从上图中我们能看到一 ...

  7. java基础 (记事本编写hello world,path,classpath,java的注释符)

    一:java的基本信息 jre 是指java运行环境,jdk 是指 java 开发工具集(并且里面是自带有jre运行环境的) jvm是指java的虚拟机 java的源代码的后缀名是 .java (例如 ...

  8. java基础(一):我对java的三个环境变量的简单理解和配置

    首先说说java的三个环境变量:java_home,classpath,path java_home:jdk的安装路径[你一层一层点开安装路径,直到当前目录有一个bin目录,然后在地址栏里面右键单击复 ...

  9. java基础-构建命令行运行的java程序简要注意

    今天编写了一个运行在服务端的java工具类,才发现自己以前很少关注运营方面的内容,导致在服务端部署一个java的工具变得异常困难,其实这也是自己对java的了解不够造成的. 首先,当代码编写完成之后, ...

随机推荐

  1. 攻防世界(二)Training-WWW-Robots

    攻防世界系列:Training-WWW-Robots 1.查看robots.txt的要求  补充: 什么是robots.txt协议? Robots.txt是放在网站根目录下的一个文件,也是搜索引擎在网 ...

  2. rsync 服务配置_rsync命令使用方法

    rsync介绍 rsync用来定时备份服务器中的文件或者目录,有三种工作模式,本地复制,使用系统用户认证,守护进程方式,开源高效.同步工具,把一台机器上的文件同步都另一台机器 .默认使用873端口 选 ...

  3. visual studio code 快捷键-(转自 浅笑千寻)

    Visual Studio Code之常备快捷键 官方快捷键大全:https://code.visualstudio.com/docs/customization/keybindings Visual ...

  4. 若依框架前端使用antd,IE11浏览器无法正常显示问题

    话不多说,直接上才艺,找到vue.config.js,把第11行的 mock 删除掉就 IE11就正常显示了, 然而项目还是不支持IE10 以及以下版本,哪位小伙伴有解决方法,可以留言交流下

  5. 快速上手 Linkerd v2 Service Mesh(服务网格)

    在本指南中,我们将引导您了解如何将 Linkerd 安装到您的 Kubernetes 集群中. 然后我们将部署一个示例应用程序来展示 Linkerd 的功能. 安装 Linkerd 很容易.首先,您将 ...

  6. nmap扫描端口导致线上大量Java服务FullGC甚至OOM

    nmap扫描端口导致线上大量Java服务FullGC甚至OOM 最近公司遇到了一次诡异的线上FullGC保障,多个服务几乎所有的实例集中报FullGC,个别实例甚至出现了OOM,直接被docker杀掉 ...

  7. Task类学习教程—组合任务ContinueWith

    Task类学习教程-组合任务.ContinueWith 一.简介 通过任务,可以指定在任务完成之后,应开始运行之后另一个特定任务.ContinueWith是Task根据其自身状况,决定后续应该作何操作 ...

  8. Vue的基本使用和模版语法

    Vue的基本使用和模版语法 一.Vue概述 Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架 vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目 ...

  9. Selenium 库的基本用法

    Selenium库的基本使用   1.基本使用 from selenium import webdriver from selenium.webdriver.common.by import By f ...

  10. 调试动态加载的js

    用浏览器无法调试异步加载页面里包含的js文件.简单的说就是在调试工具里面看不到异步加载页面里包含的js文件   最近在一个新的web项目中开发功能.这个项目的管理界面有一个特点,框架是固定的,不会刷新 ...