Java杂谈——Java多线程与并发-原理

synchronized

线程安全问题的主要原因:

  • 存在共享数据(也称临界资源)
  • 存在多条线程共同操作这些共享数据

解决这些问题的根本办法:

同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完毕再对共享数据进行操作。

互斥锁的特性:

  • 互斥性:同一时间只允许一个线程持有某个对象锁,互斥性也称操作的原子性
  • 可见性:确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的,防止引发数据的不一致

synchronized锁的不是代码,而是对象。

获取的锁可分为:

  • 对象锁
  • 类锁

获取对象锁的方法:

  1. 同步代码块(synchronized(this),synchronized(类实例对象)),锁是括号中的实例对象
  2. 同步非静态方法(synchronized method),锁是当前对象的实例对象

获取类锁的方法:

  1. 同步代码块(synchronized(类.class)),锁是括号中的类对象(Class对象)
  2. 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)

类锁和对象锁互不干扰。

synchronized底层实现原理

实现synchronized的基础

  • Java对象头
  • Monitor

对象在内存中的布局

  • 对象头
  • 实例数据
  • 对齐填充

对象头结构:

  • Mark word:默认存储对象的hashCode,分代年龄,锁类型,锁标志等信息
  • Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据

Monitor:每个Java对象天生自带了一把看不见的锁

什么是重入:当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,会处于阻塞状态,当这个线程再次请求时,属于重入。

为什么很多人不愿使用synchronized

  • 早期版本中,synchronized属于重量级锁,依赖于Mutex Lock实现
  • 线程之间的切换需要从用户态切换到核心态,开销很大

Java6以后,synchronized性能得到了很大提升:

  • Adaptive Spinning
  • Lock Eliminate
  • Lock Coarsening
  • Lightweight Locking
  • Biased Locking

自旋锁:

  • 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
  • 通过让线程执行忙循环等待锁的释放,不让出CPU
  • 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销,这里可通过PreBlockSpin设置。

自适应自旋锁:

  • 自旋的次数不再固定
  • 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

锁消除

更彻底的优化

JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁

锁粗化

另一种极端

通过扩大加锁的范围,避免反复加锁和解锁

synchronized的四种状态

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

锁膨胀方向:无锁→偏向锁→轻量级锁→重量级锁

偏向锁减少同一线程获取锁的代价

  • 大多数情况,锁不存在多线程竞争,总是由同一线程多次获得

核心思想:若一个线程获得了锁,锁就进入偏向模式,此时Mark word的结构变成偏向锁结构,当线程再次请求锁时,无须任何同步操作,即获取锁时只需检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark World的ThreadID即可,省去了大量有关锁请求操作。

不适用于锁竞争比较激烈的多线程场合

轻量级锁:

由偏向锁升级而来,偏向锁运行在一个线程进入同步块的情形,当第二个线程加入参与锁竞争时,偏向锁就转化为轻量级锁

适应场景:线程交替执行同步块

若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁

锁的内存语义:

当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中;

当线程获取锁,Java会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

偏向锁、轻量级锁、重量级锁汇总

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法仅存在纳秒级的差距 线程间存在锁竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步块或同步方法
轻量级锁 竞争的线程不会阻塞,提高响应速度 若线程长时间抢不到锁,自旋会消耗CPU性能 线程交替执行同步块或同步方法
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步方法或同步方法执行时间较长的场景

 

synchronized和ReentrantLock的区别

ReentrantLock(再入锁)

  • 位于java.util.concurrent.locks包
  • 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
  • 能够实现比synchronized更细粒度的控制,如控制fairness
  • 调用lock()之后,必须调用unlock()释放锁
  • 性能未必比synchronized高,且也是可重入的

ReentrantLock公平性的设置

  • ReentrantLock fairLock = new ReentrantLock(true);
  • 参数为true时,倾向于将锁赋予等待时间最久的线程(减少线程”饥饿“的一个办法)
  • 公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用)
  • 非公平锁:抢占的顺序不一定,看运气
  • synchronized是非公平锁

ReentrantLock将锁对象化

  • 判断是否有线程,或者某个特定线程,在排队等待获取锁
  • 带超时的获取锁的尝试
  • 感知有没有成功获取锁

是否能将wait/notify/notifyAll对象化

  • java.util.concurrent.locks.Condition

synchronized和ReentrantLock的区别

  • synchronized是关键字,ReentrantLock是类
  • ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
  • ReentrantLock可以获取各种锁的信息
  • ReentrantLock可以灵活地实现多路通知
  • 机制:sync操作Mark Word,lock调用Unsafe类地park()方法

什么是Java内存模型中的happens-before

Java内存模型JMM

Java内存模型(Java Memory Model,JMM)是种抽象的概念,并不真实存在,它描述一组规则或规范,通过这组规范定义程序中各个变量地访问方式。

JMM中的主内存

  • 存储Java实例对象
  • 包括成员变量、类信息、常量、静态变量等
  • 属于数据共享的区域,多线程并发操作会引发线程安全问题

JMM中的工作内存

  • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
  • 字节码行号指示器、Native方法信息
  • 属于线程私有数据区域,不存在线程安全问题

JMM和Java内存区域划分是不同的概念层次

  • JMM描述的是一组规则,围绕原子性、有序性、可见性展开
  • 相似点:存在共享区和私有区域

主内存与工作内存的数据存储类型以及操作方式归纳

  • 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
  • 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存
  • 成员变量、static变量、类信息均会被存储在主内存中
  • 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新主内存

JMM如何解决可见性问题

指令重排序需要满足的条件

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序

无法通过happens-before原则推导出来的,才能进行指令的重排序

A操作的结果需要对B操作可见,则A与B存在happens-before关系

volatile:JVM提供的轻量级同步机制

  • 保证被volatile修饰的共享变量对所有线程总是可见的
  • 禁止指令的重排序优化

volatile变量为何立即可见?

  • 当写一个volatile变量时,JMM会把该线程对应的工作内存中的关系变量刷新到主内存中
  • 当读取到一个volatile变量时,JMM会把该线程对应的工作内存置为无效

volatile如何禁止重排优化

内存屏障(Memory Barrier)

  1. 保证特定操作的执行顺序
  2. 保证某些变量的内存可见性

通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化

强制刷新各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本

实现线程安全的单例写法:

单例的双重检测实现:

public class Singleton {
    //禁止指令重排序优化
    private volatile static Singleton instance;

    private Singleton(){}

    public static Singleton getInstance(){
        //第一次检测
        if (instance == null){
            //同步
            synchronized (Singleton.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile和synchronized的区别

volatile和synchronized的区别
volatile synchronized
本质是告诉JVM当前变量在寄存器(工作内存)中的值不确定,需要从主内存读取 锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞朱直到该线程完成变量操作
仅使用在变量级别 可使用在变量、方法和类级别
仅能实现变量的修改可见性,不保证原子性 可保证变量修改的可见性和原子性
不会造成线程阻塞 可能会造成线程阻塞
标记的变量不会被编译器优化 标记的变量可被编译器优化

 

CAS(Compare and Swap)

一种高效实现线程安全性的方法

  • 支持原子更新操作,适用于计数器,序列发生器等场景
  • 属于乐观锁机制,号称 lock-free
  • CAS操作失败时由开发者决定是继续尝试,还是执行别的操作

CSA思想

包含三个操作数——内存位置(V)、预期原值(A)和新值(B)

CAS多数情况下对开发者是透明的

  • J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选
  • Unsafe类虽提供CAS服务,但因能够操纵任意内存地址读写而有隐患
  • Java9以后,可以使用Variable Handle API来代替Unsafe

缺点:

  • 若循环时间长,则开销很大
  • 只能保证一个共享变量的原子操作
  • ABA问题      解决:AtomicStampedReference

Java线程池

利用Executors创建不同的线程池满足不同场景的需求

1. newFixedThreadPool(int nThreads)

    指定工作线程数量的线程池

2. newCachedThreadPool()

    处理大量短时间工作任务的线程池

    1)试图缓存线程并重用,当无缓存线程可用时,救会创建新的工作线程;

    2)如果线程闲置的时间超过阈值,则会被终止并移出缓存;

    3)系统长时间闲置的时候,不会消耗什么资源

3.newSingleThreadExecutor()

    创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代他。

4.newSingleThreadScheduleExecutor()与newScheduledThreadPool(int corePoolSize)

    定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程

5.newWorkStealingPool()

    内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序

Fork/Join框架

把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架

为什么使用线程池

  • 降低资源消耗
  • 提高线程的可管理性

J.U.C的三个Executor接口

  • Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
  • ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
  • ScheduledExecutorService:支持Future和定期执行任务

ThreadPoolExecutor的构造函数

  • corePoolSize:核心线程数量
  • maximumPoolSize:线程不够用时能够创建的最大线程数
  • workQueue:任务等待队列
  • keepAliveTime:允许线程的空闲时间,抢占的顺序不一定,看运气
  • threadFactory:创建新线程,Executors.defaultThreadFactory()

handler:线程池的饱和策略

  • AbortPolicy:直接抛出异常,这是默认策略
  • CallerRunsPolicy:用调用者所在的线程来执行任务
  • DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务
  • DiscardPolicy:直接丢弃任务
  • 实现RejectedExecutionHandler接口的自定义handler

线程池的状态

  • RUNNING:能接受新提交的任务,也能处理阻塞队列中的任务
  • SHUTDOWN:不再接受新提交的任务,但可以处理存量任务
  • STOP:不再接受新提交的任务,也不处理存量任务
  • TIDYING:所有的任务都已终止
  • TERMINATED:terminated()方法执行完后进入该状态

线程池的大小如何选定

  • CPU密集型:线程数=按照核数或者核数+1设定
  • I/O密集型:线程数=CPU核数*(1+平均等待时间/平均工作时间)

你可能感兴趣的:(Java杂谈)