并发编程学习

本文详细探讨了并发编程的基础,包括进程、线程、协程的对比,以及并发问题的根源,如并发三要素。重点讲解了Java内存模型(JMM)在并发中的作用,分析了并行与并发的区别,以及Happens-Before原则。同时,文章深入讨论了Java中处理并发问题的关键字volatile和synchronized,分析了它们的工作原理和应用场景。还详细介绍了线程池的运作机制,包括线程状态、线程安全和线程池的配置参数。最后,文章列举了多种并发控制工具,如锁和阻塞队列,并讨论了在实际问题场景中的解决方案。
摘要由CSDN通过智能技术生成

并发编程

并发理论基础

进程、线程、协程

  • 进程: 操作系统进行资源分配和调度的基本单位。每个进程有独立的内存空间。进程通讯就采用
    共享内存,MQ,管道。
  • 线程:一个进程可以包含多个线程,线程就是CPU调度的基本单位。一个线程只属于某一个进
    程。
    线程之间通讯,队列,await,signal,wait,notity,Exchanger,共享变量等等都可以实现线程之间的通讯。
  • 协程:
    • 协程是一种用户态的轻量级线程。它是由程序员自行控制调度的。可以显式的进行切换。
    • 一个线程可以调度多个协程。
    • 协程只存在于用户态,不存在线程中的用户态和内核态切换的问题。协程的挂起就好像线程的
      yield。
    • 可以基于协程避免使用锁这种机制来保证线程安全。

协程和线程对比

  • 更轻量: 线程一般占用的内存大小是MB级别。协程占用的内存大小是KB级别。
  • 简化并发问题: 协程咱们可以自己控制异步编程的执行顺序,协程就类似是串行的效果。
  • 减少上下文切换带来的性能损耗: 协程是用户态的,不存在线程挂起时用户态和内核态的切
    换,也不需要去让CPU记录切换点。
  • 协程在针对大量的IO密集操作时,协程可以更好有去优化这种业务

并发出现问题的根源: 并发三要素

  • 可见性:CPU缓存引起
 //线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

可能导致执行j=i时,读取的是CPU缓存中的0,从而j=0而不是10

  • 原子性:分时复用引起
    即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
    类似:事务,银行转账操作
  • 有序性:重排序引起
    即程序执行的顺序按照代码的先后顺序执行。
    但是JVM实际在执行时,可能发生指令重排序

并发编程模型的分类

并发程序中其实就是处理两个问题:线程之间如何通信线程之间如何同步

  • 线程通信一般两种:共享内存和消息传递
    共享内存:线程共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式通信
    消息传递:线程之间没有公共状态,线程之间必须通过明确的发送消息来显式通信
  • 线程同步:不同线程之间操作的顺序。
    共享内存:同步是显示的,需要显式指定某个方法或某段代码需要再线程之间互斥执行
    消息传递:由于消息发送必须在消息的接收之前,因此是隐形同步

Java的并发采用的共享内存模型,因此通信是完全隐性的,同步需要显性

JAVA是怎么解决并发问题的: JMM(Java内存模型)

Java 内存模型(JMM)它决定每个线程对共享变量的写入何时对另一个线程可见。
在这里插入图片描述
A要向B通信必须经过两个步骤:
线程A把本地内存中的变量刷新到主内存中
线程B到主内存中读取线程A之前已更新过的共享变量

为了实现有序性,加了内存屏障指令

并行和并发的区别

它们都是Java并发编程里面的概念。简单来说就是并行指的是cpu同一时刻同时处理多个任务,像我们8核CPU并行能力就是最多同时运行8个线程。

并发就是同一时间端有多个线程运行,宏观上看是CPU处理了多个线程,但是从微观上看其实每个时刻都只是一个线程在运行,只是CPU切换速度快,频繁切换,所以就可以实现处理多个线程。

像我们平常所说的提升性能,就是提升并发处理的能力。而并行能力大小一般来说跟CPU个数有关。

对Happens-Before 的理解

首先我们知道我们的编码顺序和编译器实际的执行顺序是有区别的,是由JVM做了优化,这个就是指令重排序。而指令重排序优化了性能但是带来了可见性问题。而Happens-Before就是一个可见性模型,它规定了一些编译器重排序规则,从而保证重排序不会对结果产生影响。
JMM通过Happend-Before关系实现跨越线程的内存可见性保证。如果一个操作的结果对另外一个操作可见,那么这两个操作之间必然存在Happend-Before。
最后,JMM中定义了很多关于Happend-Before的规则:
1.单一线程原则
在一个线程内程序前面的操作先行发生于后面的操作
2.线程锁定规则
一个unlock操作先行发生于后面对同一个锁的lock操作
3.volatile 变量规则
对于一个volatile变量的写操作先行发生于后面对这个变量的读操作
4.线程启动规则
Thread对象的start()方法调用先行发生于此线程的每一个动作
5.线程加入规则
Thread对象的结束先行发生于join()方法返回
6.线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过该方法加成是否有中断发生
7.对象终结规则
一个对象的初始化完成先行发生于它的finalize()
8.传递性
如果A先于B,操作B先行发生于操作C,那么操作A先行发生于操作C

为什么需要多线程

由于CPU、内存、I/O设备速度有极大差异的,为了合理利用CPU的高性能,平衡这三者的速度差异:
CPU增加了缓存 //可见行问题
操作系统增加了进程、线程 //原子性问题
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用 //有序性问题

其实,java程序运行至少要启动两个线程,一个main线程,一个垃圾收集线程

线程不安全指什么

多个线程对一个共享数据进行访问而不采取同步操作的话,导致操作的结果可能不一致。
简单的购物(减库存),转账

线程安全不是一个非真即假的命题

一个类在可以被多个线程调用时是线程安全的那么就算线程安全。
安全程度分为:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

  1. 不可变
    不可变的对象一定线程安全,多线程环境下,应对尽量是对象成为不可变,满足线程安全
    如:
  • final关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number部分子类 :Long Double BigInteger BigDecimal 等
  • 以及用Collections.unmodifiableXXX() 方法获取一个不可变的集合(修改就会抛出异常)
  1. 绝对线程安全
    不管运行时环境如何,调用者都不需要任何额外的同步措施
  2. 相对线程安全
    Java中大部分线程安全类都属于相对线程安全(Vector),即保证这个对象单独的操作是线程安全的,调用时不需要额外的保障措施。对于特定的调用需要使用额外的同步手段。
  3. 线程兼容
    线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确使用同步手段保证并发安全,我们常说的线程不安全,多指这个情况。Java API中大部分的类都是属于线程兼容的ArrayList和HashMap
  4. 线程对立
    无论怎么处理,多线程都会出问题。而java语言天生具有多线程特性,所以比较少,应当避免。

死锁的概念和怎么避免

死锁简单来说就是多个线程对于互斥资源的争夺,并且此时它们各自都持有对方需要的资源,导致大家都在互相等待对方执行完成,释放资源,导致的无限期等待状态。

想要避免死锁的产生,我们首先要从产生死锁的条件来入手:
1.存在共享互斥资源
有共享变量
2.请求和保持资源
线程都持有部分资源,并且都在请求剩下的资源
3.线程运行的资源不可剥夺
线程运行时资源在未完成时不可剥夺
4.环路等待
死锁的发生必然存在一个环路等待的现象
一旦发生死锁就只有通过重启或者kill线程的方式去解决。所以我们必须避免死锁。

那么我们只要任意打破其中一个死锁条件,就可以避免死锁:
互斥资源这个不好处理,并发基本都有。
请求和保持,我们可以规定,每个线程在运行前必须先获得所有的资源才可以开始运行,避免了请求其他线程资源的等待。
资源不可剥夺,可以规定占有资源的线程去申请其他资源失败后,就释放所占有的资源。
一般从环路等待这个入手,比如我们给每个锁编号,然后制定一套只有固定获得锁的顺序。所有进程都必须按这个顺序获得锁,那么就可以避免循环等待,从而打破死锁

伪共享的概念和怎么避免

伪共享简单来说就是在多线程并发情况下。多个线程对并不是一个共享变量的操作,但是还是出现了争夺共享变量的情况。这个就是伪共享

之所有出现这个情况,其实是跟空间局限性这个设计思想有关。我们知道,计算机对内存的处理速度那是大大高于磁盘IO的,所以计算机就为了提高效率设计了缓存机制,而基于空间局限性的思想:计算机在读取缓存时并不是一个一个读,而是读一片数据,将查询的和它附件数据放在一个缓存行中读取出来。也正是因为这个原因,可能导致相邻的变量被不同线程请求操作时,出现互斥的原因。

避免伪共享我们的一般解决思想就是:对齐补全。
意思是一个缓存行里存了我们一个变量时(小于64k),使用一些无意义的数据去补全一个缓存行。在Java8中就有一个@Contend注解。被这个注解声明的对象或者变量会被加载到同一缓存行中。

守护线程?它和普通线程有什么区别?(守护线程=后台线程 普通线程=前台线程)

守护线程和普通线程最大的区别就是会不会阻止用户停止JVM。
守护线程单独存在是无意义的。它必须要依赖用户线程,它不适合对操作数据或者磁盘IO。因为它的存在不会影响进程是否可以退出。
它更适合做一些后台服务操作,比如JVM的垃圾回收就是守护线程的典型场景。JVM是否退出,不会受到后台是否存在垃圾回收线程的影响,因为如果JVM退出了,资源都释放了,单独的垃圾回收也就没有意义了。托管线程池中的线程都是守护线程
但是不管是前台线程还是后台线程,如果线程内出现了异常,都会导致进程的终止。判断程序是否运行完成就是看前台线程是否执行完毕。

关键字: volatile

volatile变量并能不保证线程完全安全,要保证线程安全,必须保证原子性,可见性,有序性三者缺一不可。但是volatile只能保证可见性和有序性,不能保证操作的原子性,所以对于原子操作来说是线程安全,但是非原子操作就不能保证了

可见性是基于MESI协议,保证每次修改都必须同步到内存中

  • Modified(M): 表示缓存行已被修改,并且是唯一的拷贝。当其他处理器需要读取该数据时,必须先将它的缓存行设置为Invalid状态,然后从当前处理器的缓存中拷贝数据。
  • Exclusive(E): 表示缓存行是唯一的拷贝,并且未被修改。其他处理器可以读取这个数据,但必须先将它的缓存行设置为Shared状态,然后从当前处理器的缓存中拷贝数据。
  • Shared(S): 表示缓存行是共享的,即有其他处理器也有相同的拷贝。其他处理器可以读取这个数据,但不能修改。如果当前处理器要修改这个数据,必须将缓存行状态变为Modified,然后其他处理器的缓存行变为Invalid。
  • Invalid(I): 表示缓存行无效,即不包含有效的数据。当一个处理器写入该数据时,必须将其他处理器的缓存行状态变为Invalid。
    有序性是使用内存指令屏障从而防止重排序。

有序性是基于happen before模型,即通过内存读写屏障来实现的

32位机器上共享的long和double变量的为什么要用volatile?

因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

volatile的应用场景?单例模式

  • 单例模式
    单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%
 class Singleton {
   
    private volatile static Singleton instance;
    private Singleton() {
   
    }
    public static Singleton getInstance() {
   
        if (instance == null) {
   
            syschronized(Singleton.class) {
   
                if (instance == null) {
   
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}

volatile实现单例模式的必然性?

单例模式中使用volatile,其实是为了避免编译器指令重排序导致,获取到一个不完整的对象的问题。
因为,其实在我们执行instance = new single实例对象时,它进行的操作并不是原子的,而是会被编译器编译为三条指令,分别是:

  • 为对象分配内存空间
  • 初始化对象
  • 将实例对象赋值给instance引用
    那么根据编译器重排序原则,对于不影响单线程执行的情况下,两个不存在依赖的指令允许重新排序,也就是说不一定会按照我们设想的想初始化再赋值引用。很可能就拿到一个未初始化的实例对象。所以说最好在instance这个变量上加上volatile关键字修饰,volatile底层使用了内存屏障机制来避免重新排序

关键字:Synchronized

Synchronized本质上是通过什么保证线程安全的?

synchronized是通过监视器计数的是否为0,来判定程序是否被使用,是否拥有使用权。
主要是跟监视器的monitorenter和monitorexit指令有关,保证每一个对象在同一时间只与一个监视器相关联,一个监视器只能被一个线程获取。

Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

synchronized的锁升级:偏向锁,自旋锁,重量级锁

synchronized锁升级其实就是jdk1.6以后对synchronized的性能的一个优化。它的思想就是在竞争不激烈的前提下,能使用低级的锁保证线程安全就是用低级别的锁。是一种基于安全性和性能平衡思想的体现。

简单说就是,当线程访问synchronized同步代码块时,它会根据线程竞争情况,先尝试不加重量级锁的情况下保证线程安全,从而引入了偏向锁和轻量级锁的机制。

  • 偏向锁
    直接把当前锁偏向某个线程,简单说就是通过CAS去修改偏向锁标记,这种锁适用于同一个线程多次申请锁资源并且没有其他线程竞争的场景。
  • 轻量级锁
    也可以称为自旋锁,基于自适应自旋机制,多次重试竞争锁。它就避免线程状态切换带来的性能开销

因此就是先尝试CAS偏向锁,然后尝试自旋轻量级锁,最后升级到重量级锁,在重量级锁未获取到锁资源的线程就进入阻塞状态,等待获取锁的线程触发唤醒。

synchronized编译器优化

锁消除:线程在执行一段synchronized代码块时,发现没有共享数据的操作,自动帮你把synchronized去掉。
锁膨胀:在一个多层循环的操作中频繁的获取和释放锁资源,synchronized在编译时,可能会优化到循环外部。

偏向锁会降级到无锁状态嘛?怎么降?
会,当偏向锁状态下,获取当前对象的hashcode值,会因为对象头空间无法存储hashcode,导致降级到无锁状态。

Lock接口下锁 比synchronized 的对比

  • 优点:
    1.灵活性:lock接口里面提供更多对于锁的操作,比如尝试获取锁,判断当前线程是否获得锁等等,功能更加强大。
    2.可中断性:使用lock接口获取锁的线程如果等待是可以使用interrupt()方法中断线程等待,前提是线程里面实现了isInterrupt()这个方法就可以。而synchronized只能无限期的等待
    3.公平性:lock接口实现了公平锁,synchronized只有非公平锁。而且基于lock接口的renntrantLock还实现了可重入锁

  • 缺点:lock接口使用时需要自己在finally里面释放锁,否则可能导致死锁

synchronized 和ReentrantLock 比较

  1. 锁的实现
    synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  2. 性能
    新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
  3. 等待可中断
    当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock 可中断,而 synchronized 不行。
  4. 公平锁
    公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
  5. 使用锁
    synchronized不需要自己开启关闭锁,自己释放资源
    ReentrantLock 需要自己主动调用lock(),unLock()方法(写在finally中),并且发生异常也不会主动释放锁,

使用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

Synchronized修饰的方法在抛出异常时,会释放锁吗?

多个线程等待同一个Synchronized锁的时候,JVM如何选择下一个获取锁的线程?

非公平锁,即抢占式。

synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。

关键字:final

final方法可以重写吗?可以重载吗?

父类的finl方法,子类不能重写。但是对于final方法是可以重载的。

父类的private方法对子类也是不可见,子类也不可以重写。如果把父类被final修饰的private方法改为public,则会报错。因为父类的finl方法,子类不能重写。

final的原理:内存屏障

写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。

说说final类型的类如何拓展:外观模式

一般来说final修饰的类不可继承,不可拓展。如果想实现String等类型的拓展:复用String中所有方法的同时新增一个自定义的toMyString()方法,可以考虑外观模式

  • 外观模式:
 class MyString{
   

    private String innerString;

    // ...init & other methods

    // 支持老的方法
    public int length(){
   
        return innerString.length(); // 通过innerString调用老的方法
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值