口语化讲解多线程与并发

前言

本期讲解多线程和并发的知识,涉及到线程和三个关键词synchronized、volatile、final。分别对重点问题进行了一个口语化的讲解,比较干。主要也是这一块知识点确实也没啥扩展的点,过于基础了。基础部分的口语化系列仅提供核心知识点的套路总结,因此更细的点需要大家自行查询,本文可做突击用。

正文

如何理解并发与并行、线程与进程、同步与互斥的区别?

并发:一段时间内
并行:同一时间内
进程:操作系统分配资源的最小单元
线程:操作系统进行调度的最小单元。一个程序至少有一个进程,一个进程至少有一个线程

为什么要用多线程?如何保证线程安全?

多线程是为了提升多核CPU的利用率,不让核心闲置。但是多线程会导致产生三个问题,可见性、原子性、有序性。

  • 可见性的意思是一个线程修改了共享变量,另一个线程能立刻看到修改后的数据。产生问题的根因是CPU增加了缓存,以均衡与内存的速度差异。
  • 原子性的意思是一个或多个操作,要么全部执行完毕要么全部不执行。产生问题的根因是操作系统使用进程和线程分时复用CPU,以均衡与IO设备的速度差异
  • 有序性的意思是使程序按照代码顺序执行。产生问题的跟因是编译程序优化指令执行顺序**(指令重排序**),使缓存能得到更加充分的利用

JVM通过三个关键词volatile、synchronized、final,Java内存模型(JMM)和8个happen-before原则来规范线程如何操作内存。
作为开发人员则可以通过三种方案来保证线程安全。一是阻塞同步,Java提供了synchronized和reentrentLock。二是非阻塞同步,CAS。三是无同步,局部变量和线程本地存储。局部变量在栈上分配,线程私有。线程本地存储具体实现有ThreadLocal类。两种无同步方案都是用额外的内存空间来保证线程安全

聊聊线程

线程通常有新建、就绪、执行、阻塞、销毁五种状态。新建就是new一个线程对象。就绪就是调用start方法。执行则是调用run方法。阻塞分为三种情况,等待阻塞wait,同步阻塞加锁,其他类型比如join和sleep方法。销毁则是线程运行结束或者因为意外中断结束。
通常创建线程有四种方法。一是继承Thread类,重写run方法。二是实现无返回值的Runable接口。三是实现有返回值的Callable接口。四是使用线程池创建
多说一下这里开启线程时要用Thread类的start方法而不是run方法的原因。start方法会开启一个新的线程进入就绪状态,获取时间片后就会正常执行,而直接调用run方法,会使main方法这个主线程认为这是主线程的一个普通方法,从而不会开启新线程。
线程同样存在一些通讯机制,约五种。一是volatile和synchronized两个关键词,前者修饰共享变量后,会强制其他线程从主内存获取最新数据,后者则是提供线程互斥。二是等待通知机制,通过wait、notify、notifyAll三个方法实现。三是join方法,比如我在主线程A中让线程B调用join方法,那么线程A就必须等待线程B运行完成后才能运行接下来的步骤。四是ThreadLocal,线程本地存储,额外开辟一块内存空间,能以线程的ThreadLocal作为Key,查询对应线程本地存储的数据。五是用于线程间管道通信,有专门的API。
此外由于多线程加锁的原因,有一定几率造成死锁。有四个必要条件,资源互斥、请求与保持持有资源、自身资源不会被剥夺、循环等待资源。除了互斥是锁特性导致的,其他三种情况都可以解决。请求资源一次性请求所有的,如果请求不到资源就放弃自身持有的资源,循环等待最好是提前设定好顺序获取。

聊聊Synchronized应用场景、加锁原理

Synchronized可以是对象锁或类锁,可以作用于方法和代码块上

public class MyClass {
    对象锁
    public synchronized void synchronizedMethod() {
        // 该方法被调用时,会锁定当前对象实例
    }
    public void someMethod() {
        synchronized(this) {
            // 这个代码块会锁定当前对象实例
        }
    }
    //类锁
    public synchronized static void synchronizedStaticMethod() {
        // 该方法被调用时,会锁定 MyClass 类
    }
    static {
        synchronized(MyClass.class) {
            // 这个静态代码块会锁定 MyClass 类
        }
    }
}

Synchronized本质上是通过对象的监视器锁保证线程安全的。Synchronized在修饰方法时是用ACC_SYNCHRONIZED做了标识,在修饰代码块时是用monitorenter和monitorexit两个指令实现,两者在本质上都是通过监视器锁来控制加锁和解锁。(monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。)
Synchronized加锁原理可以这样理解。首先有一些前提条件,每个对象同一时间内仅关联一个
监视器锁
,并且这个锁同一时间内只能被一个线程获取,根据监视器锁的happen-before原则,对同一个对象的解锁动作先行发生于加锁之前。监视器内部存储着当前线程的ID、内部计数器和阻塞线程的等待队列。当线程尝试获取锁时,会调用monitorenter指令,此时存在三种情况。一是如果当前计数器为0,对象头写入当前线程ID,计数器+1表示该对象已被当前线程持有。二是如果当前计数器不为0,而且线程ID与当前线程一致,那么计数器继续+1,这就是可重入性,通过计数器来实现,如果不一致则将当前线程放进监视器锁的等待队列中。三是释放锁,调用monitorexit指令,计数器-1。

Synchronized有哪些缺点,和Lock比有什么区别?

Synchronized的主要缺陷在于不够灵活,无法像Lock一样主动控制加锁和解锁的时机。Synchronized与ReentrentLock的主要区别有

  1. Synchronized是JVM关键词,ReentrentLock是JDK提供的
  2. ReentrentLock可以通过lock和unlock主动控制加锁和解锁时机
  3. ReentrentLock除了默认的非公平模式,还支持公平模式
  4. Synchronized底层使用监视器锁对象,ReentrentLock底层使用许可证模式

Synchronized的锁升级过程了解吗,JDK有什么对Synchronized的优化?

首先上结论,只有锁升级没有降级,升级的过程是无锁、偏向锁、轻量级锁、重量级锁。偏向锁是指同一个线程再次尝试持有同一个锁时,就获得偏向锁。当不同线程想要获得锁时,通过CAS替换对象头中的线程ID,此时膨胀为轻量级锁。当同时有多个线程竞争获取同一个锁时,膨胀为重量级锁,重量级锁会调用操作系统底层的互斥锁。
JDK对Synchronized优化的点有锁粗化、锁消除、适应性自旋、偏向锁、轻量级锁。锁粗化举个例子,在for循环里加锁,JVM会帮助我们把锁粗化,也就是在循环外锁住该资源。锁消除的意思是指,JVM会经过分析后将开发人员手动加的没意义的锁给消除。适应性自旋的意义在于在一定时间内不休眠线程,自循环等待锁资源,避免唤醒带来的较大开销

Synchronized的偏向锁已经被废弃了–引自阿里开发者公众号

实际执行时,如果一个线程获得了锁,那么锁就进入偏向模式,此时记录下线程ID,当这个线程再次请求锁时,无需再做任何同步操作,这样就省去了大量有关锁申请的操作。但是在真实情况中,偏向锁并不总能带来预期的性能优势,相反地,在某些情况下(多核处理器环境),偏向锁的撤销需要进入全局安全点(即safepoint,虚拟机将所有的线程暂停执行),会带来比较长的停顿时间。
偏向锁想法是好的,但是增加了JVM的复杂性,同时也并没有为所有应用都带来性能提升。因此,在JDK15中,偏向锁被默认关闭,在JDK18中,偏向锁已经被彻底废弃(无法通过命令行打开)。

聊聊volatile,有哪些作用,通过什么来保证这些功能?

防止指令重排序,保证可见性,保证单次读或写的原子性。关于原子性额外扩展两个问题,比如32位机器不能保证double这种64位的数据类型的原子性,因为会被拆分成两段,需要加volatile。i++即使加了volatile也不能保证原子性,因为i++其实是三个动作,读取变量i的值->值+1->值写回变量i。
volatile修饰变量后JMM会在操作该变量时加入一个lock指令或者说内存屏障,该指令有两个作用。

  1. 将当前线程的缓存写入主内存,并使其他线程的缓存失效,强制下次读取时读主内存最新的数据
  2. 不允许lock指令后的语句被重排序

两个功能分别保证了可见性和防止重排序
volatile应用于单例模式、Atomic原子类和ConcurrentHashMap。

final的应用范围和对应作用,底层原理

  • 用于修饰时,表示该类不可继承,比如String类。
  • 用于修饰方法时,表示该方法不可重写,但可以重载,并且JVM会尝试将其内联,提高运行速度。
  • 用于修饰变量时,如果是基本数据类型,会加入常量池,如果是引用类型,String类型也会有常量池,其他则是引用地址不可变

final底层也是通过植入内存屏障实现

写在最后

最近忙于重构一个老项目,用到了设计模式,主要是为了代码利于理解和扩展,复杂度提升了但是看着更清晰。有机会的会发文分享一波,大致思路是讲解一下,我是怎么在业务不太熟悉的情况下去找优化点,遇到了什么问题,具体重构了什么?比如如何解套多层for循环,如何善用多线程。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值