Java核心(三)并发中的线程同步与锁

乐观锁、悲观锁、公平锁、自旋锁、偏向锁、轻量级锁、重量级锁、锁膨胀…难理解?不存的!来,话不多说,带你飙车。

上一篇介绍了线程池的使用,在享受线程池带给我们的性能优势之外,似乎也带来了另一个问题:线程安全的问题。

那什么是线程的安全问题呢?

一、线程安全问题的产生

线程安全问题:指的是在多线程编程中,同时操作同一个可变的资源之后,造成的实际结果与预期结果不一致的问题。

比如:A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。

如果上面的内容您还没有理解,没关系,我们来看下面非安全线程的模拟代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ThreadSafeSample {
    public int number;
    public void add() {
        for (int i = 0; i < 100000; i++) {
            int former = number++;
            int latter = number;
            if (former != latter-1){
                System.out.printf("非相等 former=" +  former + " latter=" + latter);
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        ThreadSafeSample threadSafeSample = new ThreadSafeSample();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                threadSafeSample.add();
            }
        });
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                threadSafeSample.add();
            }
        });
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

我电脑运行的结果: 非相等 => former=5555 latter=6061

可以看到,仅仅是两个线程的低度并发,就非常容易碰到 former 和 latter 不相等的情况。这是因为,在两次取值的过程中,其他线程可能已经修改了number.

二、线程安全的解决方案

线程安全的解决方案分为以下几个维度(参考《码出高效:Java开发手册》):

  • 数据单线程可见(单线程操作自己的数据是不存在线程安全问题的,ThreadLocal就是采用这种解决方案);
  • 数据只读;
  • 使用线程安全类(比如StringBuffer就是一个线程安全类,内部是使用synchronized实现的);
  • 同步与锁机制;

解决线程安全核心思想是:“要么只读,要么加锁”,解决线程安全的关键在于合理的使用Java提供的线程安全包java.util.concurrent简称JUC。

三、线程同步与锁

Java 5 以前,synchronized是仅有的同步手段,Java 5的时候增加了ReentrantLock(再入锁)它的语义和synchronized基本相同,比synchronized更加灵活,可以做到更多的细节控制,比如锁的公平性/非公平性指定。

3.1 synchronized

synchronized 是 Java 内置的同步机制,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。

3.1.1 synchronized 使用

synchronized 可以用来修饰方法和代码块。

3.1.1.1 修饰代码块

1
2
3
4
5
synchronized (this) {
    int former = number++;
    int latter = number;
    //...
}

3.1.1.2 修饰方法

1
2
3
public synchronized void add() {
    //...
}

3.1.2 synchronized 底层实现原理

synchronized 是由一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。在 Java 6 之前,Monitor的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作,性能也很低。但在Java 6的时候,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏向锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。

3.1.2.1 偏向锁/轻量级锁/重量级锁

偏向锁是为了解决在没有多线程的访问下,尽量减少锁带来的性能开销。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

3.1.2.2 锁膨胀(升级)原理

Java 6 之后优化了 synchronized 实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,减低了锁带来的性能消耗,也就是我们常说的锁膨胀或者叫锁升级,那么它是怎么实现锁升级的呢?

锁膨胀(升级)原理: 在锁对象的对象头里面有一个ThreadId字段,在第一次访问的时候ThreadId为空,JVM让其持有偏向锁,并将ThreadId设置为其线程id,再次进入的时候会先判断ThreadId是否尤其线程id一致,如果一致则可以直接使用,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,不会堵塞,执行一定次数之后就会升级为重量级锁,进入堵塞,整个过程就是锁膨胀(升级)的过程。

3.1.2.3 自旋锁

自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

3.1.2.4 乐观锁/悲观锁

悲观锁和乐观锁并不是某个具体的“锁”而是一种是并发编程的基本概念。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

乐观锁则与 Java 并发包中的 AtomicFieldUpdater 类似,也是利用 CAS 机制,并不会对数据加锁,而是通过对比数据的时间戳或者版本号,来实现乐观锁需要的版本判断。

3.1.2.5 公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。

如果使用 synchronized 使用的是非公平锁,是不可设置的,这也是主流操作系统线程调度的选择。通用场景中,公平性未必有想象中的那么重要,Java 默认的调度策略很少会导致 “饥饿”发生。非公平锁的吞吐量大于公平锁。

非公平锁吞吐量大于公平锁的原因:

比如A占用锁的时候,B请求获取锁,发现被A占用之后,堵塞等待被唤醒,这个时候C同时来获取A占用的锁,如果是公平锁C后来者发现不可用之后一定排在B之后等待被唤醒,而非公平锁则可以让C先用,在B被唤醒之前C已经使用完成,从而节省了C等待和唤醒之间的性能消耗,这就是非公平锁比公平锁吞吐量大的原因。

3.2 ReentrantLock

ReentrantLock只能修饰代码块,使用ReentrantLock必须手动unlock释放锁,不然锁永远会被占用。

3.2.1 ReentrantLock 使用

1
2
3
4
5
6
7
ReentrantLock reentrantLock = new ReentrantLock(true); // 设置为true为公平锁,默认是非公平锁
reentrantLock.lock();
try {

}finally {
    reentrantLock.unlock();
}

3.2.2 ReentrantLock 优势

  • 具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁;

  • 能被中断地获取锁的特性:与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放;

  • 超时获取锁的特性:在指定的时间范围内获取锁;如果截止时间到了仍然无法获取锁则返回。

3.2.3 ReentrantLock 注意事项

  • 在finally中释放锁,目的是保证在获取锁之后,最终能够被释放;
  • 不要将获取锁的过程写在try块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放;
  • ReentrantLock提供了一个newCondition的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作;

3.3 synchronized和ReentrantLock区别

从性能角度,synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大。但是在 Java 6 中对其进行了非常多的改进,在高竞争情况下,ReentrantLock 仍然有一定优势。在大多数情况下,无需太纠结于性能,还是考虑代码书写结构的便利性、可维护性等。

主要区别如下:

  1. ReentrantLock使用起来比较灵活,但是必须有释放锁的配合动作;
  2. ReentrantLock必须手动获取与释放锁,而synchronized不需要手动释放和开启锁;
  3. ReentrantLock只适用于代码块锁,而synchronized可用于修饰方法、代码块等;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
完整版:https://download.csdn.net/download/qq_27595745/89522468 【课程大纲】 1-1 什么是java 1-2 认识java语言 1-3 java平台的体系结构 1-4 java SE环境安装和配置 2-1 java程序简介 2-2 计算机的程序 2-3 java程序 2-4 java类库组织结构和文档 2-5 java虚拟机简介 2-6 java的垃圾回收器 2-7 java上机练习 3-1 java语言基础入门 3-2 数据的分类 3-3 标识符、关键字和常量 3-4 运算符 3-5 表达式 3-6 顺序结构和选择结构 3-7 循环语句 3-8 跳转语句 3-9 MyEclipse工具介绍 3-10 java基础知识章节练习 4-1 一维数组 4-2 数组应用 4-3 多维数组 4-4 排序算法 4-5 增强for循环 4-6 数组和排序算法章节练习 5-0 抽象和封装 5-1 面向过程的设计思想 5-2 面向对象的设计思想 5-3 抽象 5-4 封装 5-5 属性 5-6 方法的定义 5-7 this关键字 5-8 javaBean 5-9 包 package 5-10 抽象和封装章节练习 6-0 继承和多态 6-1 继承 6-2 object类 6-3 多态 6-4 访问修饰符 6-5 static修饰符 6-6 final修饰符 6-7 abstract修饰符 6-8 接口 6-9 继承和多态 章节练习 7-1 面向对象的分析与设计简介 7-2 对象模型建立 7-3 类之间的关系 7-4 软件的可维护与复用设计原则 7-5 面向对象的设计与分析 章节练习 8-1 内部类与包装器 8-2 对象包装器 8-3 装箱和拆箱 8-4 练习题 9-1 常用类介绍 9-2 StringBuffer和String Builder类 9-3 Rintime类的使用 9-4 日期类简介 9-5 java程序国际化的实现 9-6 Random类和Math类 9-7 枚举 9-8 练习题 10-1 java异常处理 10-2 认识异常 10-3 使用try和catch捕获异常 10-4 使用throw和throws引发异常 10-5 finally关键字 10-6 getMessage和printStackTrace方法 10-7 异常分类 10-8 自定义异常类 10-9 练习题 11-1 Java集合框架和泛型机制 11-2 Collection接口 11-3 Set接口实现类 11-4 List接口实现类 11-5 Map接口 11-6 Collections类 11-7 泛型概述 11-8 练习题 12-1 多线程 12-2 线程的生命周期 12-3 线程的调度和优先级 12-4 线程的同步 12-5 集合类的同步问题 12-6 用Timer类调度任务 12-7 练习题 13-1 Java IO 13-2 Java IO原理 13-3 流类的结构 13-4 文件流 13-5 缓冲流 13-6 转换流 13-7 数据流 13-8 打印流 13-9 对象流 13-10 随机存取文件流 13-11 zip文件流 13-12 练习题 14-1 图形用户界面设计 14-2 事件处理机制 14-3 AWT常用组件 14-4 swing简介 14-5 可视化开发swing组件 14-6 声音的播放和处理 14-7 2D图形的绘制 14-8 练习题 15-1 反射 15-2 使用Java反射机制 15-3 反射与动态代理 15-4 练习题 16-1 Java标注 16-2 JDK内置的基本标注类型 16-3 自定义标注类型 16-4 对标注进行标注 16-5 利用反射获取标注信息 16-6 练习题 17-1 顶目实战1-单机版五子棋游戏 17-2 总体设计 17-3 代码实现 17-4 程序的运行与发布 17-5 手动生成可执行JAR文件 17-6 练习题 18-1 Java数据库编程 18-2 JDBC类和接口 18-3 JDBC操作SQL 18-4 JDBC基本示例 18-5 JDBC应用示例 18-6 练习题 19-1 。。。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值