操作系统之进程和线程及JVM怎么实现Java线程安全

1.进程的定义和CPU执行

我们写的代码存在硬盘里,编译后生成二进制可执行文件,当运行时会存到内存里,接着CPU执行指令,运行的程序就被称为进程。

但是硬盘的读写速度很慢,如果CPU慢慢等硬盘返回数据的话,利用率就很低。所以当进程去硬盘读取数据时,CPU不需要阻塞等待数据的返回,而是执行其他进程。当数据返回时,CPU就会中断再继续运行这个进程。

虽然单核CPU在某一瞬间只能运行一个进程。但在1秒期间可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。

并发和并行的区别?

进程的状态:分为运行,就绪,阻塞状态,创建和结束状态。

PCB是进程存在的唯一标识,PCB里面包含进程和用户标识符,进程状态和进程优先级,内存空间信息,CPU相关信息。每个PCB通过链表的方式进行组织,把具有相同状态的进程组成在一起,分为就绪队列和阻塞对象。

2.进程的上下文切换

各个进程是共享CPU资源的,在不同时候进程之间需要切换。

任务是交给 CPU 运⾏的,那么在每个任务运⾏前, CPU 需要知道任务从哪⾥加载,⼜从哪⾥开始运⾏。 所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器
CPU 寄存器是 CPU 内部⼀个容量⼩,但是速度极快的内存(缓存)。我举个例⼦,寄存器像是你的⼝ 袋,内存像你的书包,硬盘则是你家⾥的柜⼦,如果你的东⻄存放到⼝袋,那肯定是⽐你从书包或家⾥柜 ⼦取出来要快的多。
所以说, CPU 寄存器和程序计数是 CPU 在运⾏任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下⽂
CPU 上下⽂切换就是先把前⼀个任务的 CPU 上下⽂( CPU 寄存器和程序计数器)保存起来,然后加载新 任务的上下⽂到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运⾏新任务。
进程是由内核管理和调度的,所以进程切换只发生在内核态。

3.线程

 但是这样画面和声音不连贯,影响使用效率。

而设计成多进程会存在进程间如何通信?如何共享数据?维护进程的开销大。

所以需要线程可以并发运行并且共享相同的地址空间。线程是进程当中的一条执行流程。

线程的优缺点?

优点:一个进程中可以存在多个线程;各个线程间可以并发执行;线程间共享空间和文件资源。

缺点:一个线程崩溃,会影响进程中所有线程;

JVM层面的Java线程安全

线程安全

       当多个线程同时访问一个对象,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要考虑额外的同步,或者在调用方法时进行一些其他的协作,调用这个对象的行为都可以获得正确的结果。那么就称这个对象是线程安全的。

       ​ 这个定义是严谨并且有可操作性的,他要求线程安全的代码都必须具备一个共同的特性。代码本身封装了所有必要的正确性保障手段(如互斥同步等)。令调用者无需关心多线程下的调用问题。更无需自己实现任何措施来保证安全。

Java中的线程安全

​ 在Java语言中,从JVM底层来看的话,线程安全并不是一个非黑即白的二元排他选项,按照安全程度来划分,我们可以将Java中各种操作共享的数据分为五类:不可变绝对线程安全相对线程安全线程兼容线程对立

在Java中,实现线程安全,主要有三种方案, 互斥同步非阻塞同步无同步方案。

互斥同步(悲观锁)

synchronized的实现

此关键字经过javac编译后,会产生两条字节码指令:monitorenter 和 monitorexit。

这两个指令都需要一个引用类型的参数来指明需要锁住的对象。如果代码中指定了,则使用指定的对象锁,如果出现在方法声明位置,那么虚拟机会判断,如果是实例方法则锁实例对象,如果是静态方法则锁类对象。

     在执行monitorenter时,首先虚拟机会尝试获取对象锁

  • 如果获取到对象锁,或者当前线程已经有了此对象锁
    • 则将对象锁中对象头位置的锁计数器+1,
    • 在执行到monitorexit时,会将其-1。一旦当前锁对象的锁计数器为0,则当前线程就会释放对象的对象锁。
  • 如果获取不到,则当前线程进入阻塞状态。直到对象锁的值变为0。也就是持有对象锁的线程释放该锁。

特征:

  • 可重入的,同一条线程进入同步块多次也不会被锁死。
  • 在同步块中执行的线程会无条件的阻塞其他线程的进入。这意味着无法像处理数据库那样强制让已获取锁的线程释放锁,也无法让正在的等待锁的进程退出。

从执行的成本来看,synchronized是一个重量级的操作。主流的Java虚拟机实现中,Java的线程是映射到操作系统的内核线程中的,如果要唤醒或者阻塞一个线程,需要从用户态切换到内核态。这种转化是很耗时的。所以synchronized是一个重量级的操作。在有必要的情况下,再去使用。

lock的实现

在JDK1.5之后,Java类库中新提供了java.util.concurrent包,其中的locks.Lock接口便成为Java另外一种互斥同步的手段。

​ 该接口的定义如下:

public interface Lock {

    //获取锁。如果锁已被其他线程获取,则进行等待。
    void lock();

    //如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。
    void lockInterruptibly() throws InterruptedException;

    //尝试获取锁,如果获取成功,则返回true 否则返回false 立即返回 不会和lock一样等待
    boolean tryLock();

    //拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    void unlock();

    Condition newCondition();
}

使用Lock接口的实现类,用户可以以非块结构来实现互斥同步,从而摆脱了语言的束缚,改为在类库层面去实现同步,这也为日后扩展出不同的调度算法,不同的特性,不同性能的各种锁提供了空间。

​ 重入锁(ReentrantLock)是Lock接口中最常见的一种实现方式。故名思意,他和synchronized一样是可以重入的。写法如下:

public static void main(String[] args) {

        Lock lock = new ReentrantLock();
        
        lock.lock();
        try{
            //处理任务
        }catch(Exception ex){

        }finally{
            lock.unlock();  //释放锁 不在finally处释放可能会发生死锁 
        }
    }

相比synchronizedReentrantLock增加了如下的功能。

  • 等待可中断 ​ 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待。对于处理执行时间比较长的同步块很有帮助
  • 公平锁 ​ 当多个线程在等待同一个锁的时候,必须按照申请锁的顺序来依次获得锁。syn 是非公平的,reentrantLock默认也是非公平的,需要在构造函数中传入true指定使用公平锁。(使用公平锁会导致性能急剧下降)
  • 锁绑定多个条件 ​ 一个ReentrantLock对象可以同时绑定多个Condition对象。只需要多次调用newCondition方法即可。

这种互斥同步的放方案主要问题是在线程阻塞和唤醒的时候会带来性能开销问题。从解决问题的方式上看,互斥同步(阻塞同步)属于一种悲观的并发策略,认为只要是别的线程过来,就一定会修改数据。无论是否真的会修改,他都会进行加锁(此处讨论的是概念模型,实际虚拟机会优化一些不必要的加锁)。这会导致用户态和内核态频繁切换,并且需要维护锁的计数器。比较繁琐。

扩展:

信号量在本质上是一种计数器。当一个线程要访问共享资源的时候,需要先读取信号量的值来判断资源是否可用:

  • 信号量 > 0:表示资源可用请求,对信号量-1。
  • 信号量 = 0:无资源可用,进程会进入睡眠状态,放入到等待队列直至资源可用。
  • 当这个线程访问共享资源完毕之后,会将信号量+1

互斥与信号量本质区别:
可以看到互斥和信号量在信号量为0和1的时候非常的类似,那么互斥与信号量到底有什么区别呢?

直接一句话说本质就是,这两个操作的目的不同。

  • 信号量解决的问题是线程之间的调度,比如说线程C要在线程A和线程B之后执行,那么只需要在线程A和线程B中嵌入信号量即可,线程A和线程B对信号量增加,线程C等待线程A和线程B将信号量释放后才执行。就实现了信号量对线程的调度。
  • 而互斥所要解决的问题是多个线程同时访问共享资源的时候,只能有一个线程操作的问题。

非阻塞同步(乐观锁)

基于冲突检测的乐观并发策略。

​ 通俗的说,就是不管风险,先进行操作。如果数据没有被修改,则修改成功。如果数据被修改,则不断重试。直到出现没有竞争的共享数据为止。

​ 此种方案需要硬件的发展,因为进行检测是否修改最终写入这两个操作必须保证原子性。如果这里用前边的互斥同步来解决,就没有什么意义了,所以需要硬件层面的支持。确保在语义上看起来有多个操作的行为只需要一条处理器指令就可以完成。常见的这种指令有

  • 测试并设置 TestAndSet
  • 获取并增加 FetchAndIncrement
  • 交换 Swap
  • 比较和交换: CompareAndSwap

在Java中完成乐观锁用的是比较和交换CAS指令。 CAS指令需要有三个操作数,一个是旧的预期值A,一个是内存位置V,还有一个新值B。 ​ 当旧的预期值与内存中真正的值相同的时候,就将旧值替换为新值。否则就不更新。 在JDK1.5之后,Java类库中才开始使用CAS操作,该操作由 sun.misc.Unsafe类中的方法包装提供。虚拟机会对这些方法进行特殊处理,保证编译之后是一条平台相关的处理器CAS指令。保证原子性的LUA脚本。

        比如AtomicInteger就是包装了CAS指令之后的线程安全类,还有volatile,他的方法都设置在一个死循环中,不断尝试将一个新值赋给内存位置的值,如果失败,说明被其他线程改了,于是再次循环进行下一次操作,直到修改成功位置。 尽管CAS看起来很美好,但是它存在一个逻辑漏洞,当别的线程将值从A改为B,然后又改回A的时候,当前线程是不会发现的。这个漏洞叫做CAS的ABA问题,JUC为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference。它通过控制变量值的版本来解决。

无同步方案实现线程安全

安全发布-final底层原理,编译器会在final写入后,插入一个store屏障,成员为final的读取一定是在对象引用的读取完之后才能读取。

其中单例模式中的饿汉式也使用DCL双重检查,避免过多的同步操作。但是这用DCL同样存在问题,存在的问题就是在多线程情况下,如果Singleton存在成员变量,多个线程下,可能会读取到对象的默认值,就是对象的不安全发布引起的。

所以对于DCL的修正就是将对象里面的每个元素都声明为final的。也可以将对象的引用声明为volatile的,因为对于volatile对象的读取和修改一定是最新的,是不允许指令重排的,可以保证读取到的值是最新值。
 

public class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    return instance;
    }
}

无同步方案又有几种实现方案:

  • 对象不共享
  • 线程本地变量
  • 不可变对象

对象不共享和不可变对象实质上是从根本上就不使用可以共享的变量,不共享不可变就不会产生安全问题了(只有死人才不会说话)。

对象不共享好理解,就是不使用共享资源。比如final,private。

不可变对象就是一点被创建,对象的属性值就不可以改变,任何对他的改变都会生成一个新的对象。顺便提一下,生成不可变对象的类就叫做不可变类(Immutable Class),例如String、基本类型的包装类、BigInteger、BigDecimal等。


线程封闭:直接联想java的ThreadLocal即可,内部维护了一个map,key是每个线程名称,value就是需要封闭的对象,每个线程在操作的时候都会到map中寻找自己的对象,每个操作都是基于自己线程的,所以他是线程安全的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值