《Java并发编程之美》阅读笔记(二)并发编程的其他基础知识

2.1 什么是多线程并发编程

首先明确并发和并行的区别,并发指的是在同一个时间段内有多个线程任务同时都在执行,且都没有结束;并行指的是在同一个单位时间内有多个线程任务同时都在执行。一个时间段是由多个单位时间积累而成的。区别于并行,并发强调是在一个时间段内,这些线程任务不一定是在同一个单位时间内都在执行。

并发:以单cpu的环境为例,为了提高执行效率,多个线程任务是并发执行的。由于cpu只能被一个线程占用,采用了时间片轮转的方式让多个线程轮换使用cpu。暂时没有获得cpu使用权的线程就会挂起等待分配时间片。此时我们称挂起的线程和正在占用cpu并运行的线程为并发线程。但是,实际上单cpu采用并发编程并没有好处,频繁的上下文切换还会浪费时间并带来额外的开销。这里只是为了说明并发的概念。

并行:双cpu环境下有两个线程,它们各占用一个cpu运行自己的任务。此时就是并行的。

但实际情况中,线程数往往大于cpu数,此时就需要并发编程来提高执行效率了。

2.2 并发编程的必要性

上边说到,在单cpu的环境下,频繁的上下文切换反而会导致效率下降。但随着多cpu时代的到来,每个线程可以使用自己的cpu。但是现在系统性能和数据请求的吞吐量日渐庞大,线程数远远超过了cpu数,此时利用并发编程适当的进行上下文切换可以大大提高执行效率。

2.3 Java中的线程安全问题

线程不安全是由于共享资源导致的,共享资源指的是可以被多个线程持有或操作的资源。

在没有任何同步措施的情况下,共享资源同时被多个线程进行读写操作就可能会出现脏读或其他不可预期的问题,此时线程就是不安全的。出现问题的关键在于写操作,如果所有线程只是读取共享资源那么不会出现线程不安全的问题。

反而言之,线程安全就是在任何情况下,多个线程任务都能最终得到预期的执行结果(即最终执行结果不存在二义性)。

2.4 Java中共享变量的内存可见性问题

首先我们看看,Java内存模型:

Java内存模型规定,所有变量都存于主内存中,当线程需要使用共享变量时将共享变量从主内存复制到自己的工作空间(工作内存)中,这就是读操作。线程实际操作的是这个副本,写操作就是将这个副本赋值给主内存中的共享变量(相当于一个更新操作)。

Java内存模型是一个抽象模型,实际的工作时的内存模型是如下图:

如图是个双核内存模型,每个线程下有自己的cpu。这里线程的工作内存指控制器中的cpu寄存器、1级缓存或2级缓存。线程将需要的共享变量从主内存复制到工作内存中,操作完后再更新到主内存。

需要知道,线程下各自的cpu的1级缓存互不可见,由于内存的不可见性就会导致了线程不安全。

以上图模型为例:

假设:线程A、B均进行增1操作,共享变量X初始值为0

1、线程A需要使用变量X,两级缓存都不能命中,它会从主内存中复制共享变量X到工作内存中。进行加1操作后更新到主内存,此时两级缓存和主内存下变量X都是1.

2、线程B需要使用变量X,1级缓存不命中,2级缓存命中。此时2级缓存中X=1,没有问题。执行加1,x=2。更新到线程B的1级缓存、公共2级缓存和主内存。

3、线程A需要使用变量X,1级缓存命中X=1。此时就有问题了,实际上已经X=2了,但是线程A下的1级缓存X=1。这就是两个线程之间1级缓存不可见导致的线程安全问题,也就是说B线程写入自己1级缓存的内容A线程是不可见的。

内存不可见的问题其实是可以解决的。使用Java中的关键字volatile或synchronized就可以解决。以后会学到。

2.5 Java中的synchronized关键字

2.5.1 synchronized关键字介绍

synchronized是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这种Java内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。线程的执行代码进入synchronized代码块之前会自动获取内部锁,其他线程执行到该代码块时会暂时挂起,在获取到内部锁的线程正常执行结束或抛出异常或调用wait系列方法而进入挂起状态后会释放内部锁,由其他线程竞争得到并执行这段synchronized代码块。

synchronized内部锁是排他锁,即同一时间内只有一个线程可以持有。其他线程必须等待锁被释放后才能尝试重新获取。

另外,Java的线程和系统的原生线程是一一对应的,当阻塞一个Java线程时,需要把一个线程从用户态转换为内核态执行阻塞操作。这是十分耗时间的。利用synchronized实现同步必然会导致上下文切换。

2.5.2 synchronized的内存语义

前面说到了由于线程的工作内存不可见性,会导致线程不同步的安全性问题。而synchronized可以解决这个问题。

在进入synchronized代码块会清除线程工作内存的共享变量,从而让线程直接去主内存获得共享变量。在退出synchronized代码块会将代码块内的共享变量刷新到主内存。

这其实也是获得锁和释放锁语义。获得锁时会把需要用到的共享变量从线程的工作内存中删除,并在使用时从主内存中获取过来;释放锁时会将共享变量从本地内存刷新到主内存。

除了可以解决内存不可见的问题,synchronized还常被用来进行原子性操作。要注意的是synchronized会导致上下文切换增加线程调度的开销。

2.6 Java中的volatile关键字

上边介绍了使用synchronized关键字解决工作内存不可见问题。但是使用synchronized这个内部锁进行线程同步会因为线程上下文切换而导致开销增大。

volatile关键字是一种弱同步,也可以解决线程工作内存不可见的问题。声明了volatile关键字的共享变量不会缓存到工作内存(寄存器或其他地方),只会刷新到主内存,即:用时从主内存中拿出来,用完再放回主内存。这样就保证了所有线程使用的共享内存能保持一致。

以上两个关键字都能解决内存不可见性问题。但synchronized是一种排他锁,可以保证线程同步。而volatile只是解决内存不可见问题的途径,并不能保证线程同步

使用volatile关键字的情形:

1、写入变量不依赖变量的当前值时,因为volatile是非原子性的,不能保证读取-计算-写入这个过程能完整执行。

2、读写的变量没有加锁时,因为加锁已经解决了内存不可见性问题,此时就无需再使用volatile了。

2.7 Java中的原子性操作

原子性指的是:执行一系列操作时,要么这些操作全都执行,要么全都不执行,不存在只执行一部分的情况。

如下代码是线程不安全的,因为不能保证num++的原子性。自增操作分为 读-改-写 三部分。

public class NotSynchThread {
    private int num = 0;
    int getNum(){
        return num;
    }
    void inc(){
        num++;
    }
}

为了实现原子性和解决内存不可见性可以使用synchronized关键字:

public class NotSynchThread {
    private int num = 0;
    int synchronized getNum(){
        return num;
    }
    void synchronized inc(){
        num++;
    }
}

但是使用synchronized会增大开销,而getNum()只是一个读操作不需要保证原子性。但我们不能在getNum方法上去掉synchronized关键字因为要靠他实现num的内存可见性。这种做法显然不好,下边请看一种更好的实现。

2.8 Java中的CAS操作

Java中进行并发处理往往会使用锁,但是锁的使用会导致未获得锁的线程被挂起,上下文的切换会增大开销。volatile能解决内存不可见问题,并且弥补了锁带来的开销过大问题,但是他不能保证操作的原子性,靠它无法实现线程同步。

CAS即Compare And Swap(比较-更新),它是JDK提供的非阻塞原子性操作,它通过硬件保证了比较-更新操作的原子性。JDK里面提供了一系列的compareAndSwap*方法,下边以compareAndSwapLong为例:

ABA问题:无法保证操作的数据是预期的原数据。数据x,可能在线程1操作x未完成时,线程2已经将数据进行了从x->y->x的操作了,虽然表面上线程1操作的还是x,但在内存中的地址(偏移量)可能已经变了。

利用Unsafe类可以从硬件层面避免操作的数据内存地址(偏移量)变动的问题。该类在操作数据时会先比较数据的偏移量。

2.9 Unsafe类

2.9.1 Unsafe类中的重要方法

 2.10 Java指令重排序

在Java中,编译器会对没有依赖关系的指令进行重新排序。这种机制可能会影响多线程的执行结果。

package com.learnThread.demo.part2;

/**
 * @Author: tongys
 * @Date: 2019/12/30
 */
public class RerollTest {
   static int num = 0;
   static boolean ready = false;
    static class Thread1 implements Runnable{

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()){
                if (ready){
                    System.out.println(num+num);//(1)
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
    static class Thread2 implements Runnable{

        @Override
        public void run() {
            num = 2;//(2)
            ready = true; //(3)
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Thread1());
        Thread thread2 = new Thread(new Thread2());

        thread1.start();
        thread2.start();
    }
}

上边这段代码,他的输出结果一定是4吗?其实不一定,他的结果可能是0或4 。由于Java编译器的重排序,代码中被标记的语句执行顺序可能是(2)(3)(1)输出4,也可能是(3)(1)(2)输出0 。

为了解决重编译带来的问题,可以给ready加上volatile关键字修饰volatile写之前的代码不会重排序到写之后,volatile读之后的代码不会重排序到读之前。此时(2)必定会在(3)之前执行。

2.11 伪共享

2.11.1 什么是伪共享

为了解决cpu和主内存之间运行速度差的问题,会在cpu和主内存之间添加一级或多级高速缓冲存储器。这个缓存是被集成到cpu内部的,所以也叫Cpu Cache。如图是二级缓存结构:

在Cache内部一般是按行存储,其中每一行称为一个Cache行。Cache行是Cache与主内存进行数据交换的基本单位,每个Cache行的大小一般是2的次幂个字节。

当Cpu访问一个变量时,会先看Cpu Cache中有没有,如果有就直接获取使用。没有就去主内存里面获取该变量,然后把该变量所在区域的一个Cache行大小的内存复制到Cache中。由于是直接把一个Cache行大小的内存块复制进了Cache中,所以可能一个内存行内可能存放了多个变量。

当有多个线程需要修改同一个缓存行内的多个变量时,由于对一个缓存行同时只能有一个线程操作它。所以相比一个缓存行只存放一个变量,这会降低执行效率,这就是伪共享。

看上边这张图,变量下x,y保存到两个线程的一级缓存和共享二级缓存中。当Thread1修改x的值,它会从操作一级缓存中的x变量,然后刷新到 二级缓存和主内存中,并且为了符合一级内存一致性,Thread2中x变量所在的内存行直接失效。当Thread2需要操作x变量时,需要到二级缓存拿到x,而二级缓存要比一级缓存慢得多。更糟糕的情况是如果Cpu只有一级缓存,那会导致更频繁的去主内存获取变量。

2.11.2 为什么会出现伪缓存

伪缓存的产生是因为多个变量被放进了同一个缓存行内,并且多个线程同时去写入统一缓存行中的不同变量。多个变量被放入一个缓存行中是因为缓存行内是直接放了一个缓存行大小的内存块,这个内存块里可能有不只一个变量。

这里注意,只有内存地址上在目标变量x附近(更多是连续的)的变量才有可能随着获取x变量而一并被复制到缓存行中。

这一机制在单线程下对于操作数组来说是有好处的,如果一个数组内保存数据的地址是连续的,那么在主内存获取数组内的一个数据时会一并获取到附近的其他数据到缓存行。此时再需要操作数组内的其他数据就可以直接从缓存中获取,能大大提高效率。但是多线程下并发修改同一个缓存行内的数据会竞争缓存行使用权,还是会降低效率。

2.11.3 如何避免伪共享

再JDK1.8之前,针对伪共享出现的原因通过填充字节的方法来避免。

假设一个内存行有64个字节,创建一个变量value和六个填充用变量。每个变量8字节,共56字节。FilledLong是一个类对象,类对象的字节码的对象头占用8字节。所以共64字节,正好占了一个缓存行。那么此时放入缓存行时,此缓存行就不存在除value之外的其他有意义的变量了。

JDK8提供了一个sun.misc.Contended注解,作用是填充类或变量的宽度,用来解决伪共享问题。将上边代码修改如下:

该注解可以修饰类,也可以修饰变量。

需要注意的是,@Contended注解只用于Java核心类,比如rt包下的类。如果用户类路径下的类需要使用,必须添加JVM参数:-XX:-RestrictContended。填充的默认宽度是128,可以自定义填充宽度,使用以下参数:-XX:ContendedPaddingWidth。

2.11.4 小结

伪共享的情况在多线程下并发访问同一缓存行时会降低执行效率。避免伪共享的方法是利用字节填充,JDK8之前手动填充字节,JDK8则提供了@Contended注解进行字节填充,可适用于类和变量。

2.12 锁的概述

2.12.1 乐观锁和悲观锁

乐观锁和悲观锁是数据库中引入的名词,但是在并发锁里也引入了这个概念。

·悲观锁是指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理之前就对数据加排他锁,在整个处理过程完成前都处于锁定状态。在获得锁的线程操作完成并释放锁之前,其他线程不能对该数据进行操作。

·乐观锁是指认为数据的操作在一般情况下不会起冲突,在访问数据时不会加排他锁,只会在提交修改时检测是否有冲突。例如,在数据库中添加version字段用于冲突检测,多个线程同时试图修改某数据时,会获取当前version并进行+1操作。其中一个线程提交时检测获得version是否与数据库中一致,一致则提交修改成功同时version字段也+1了。那么其他线程再提交则会检测到自己获得的version已经与数据库不一致了,此时就不能提交成功了。

注意,乐观锁并没有使用任何锁机制,只是在逻辑上进行了“加锁”。所以也不会产生任何线程死锁的现象。

2.12.2 公平锁和非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。公平锁指线程获取锁的先后按照请求锁的顺序来,先请求的就先获得锁。

非公平锁指线程获取锁的顺序没有规律,先请求的不一定先获得。

   ReentrantLock提供了公平锁和非公平锁的实现。

假如有3个线程ABC,当前线程A已经持有了锁,当线程A释放锁后,如果是非公平锁则线程B和C都有可能获得抢占到锁,不需要任何干涉。如果是公平锁并且线程B先请求线程,那么此时需要将线程C挂起,保证线程B能获得锁。

所以在没有公平性需求的情况下优先使用非公平锁,因为公平锁会带来额外的性能开销。

2.12.3 共享锁和独占锁

根据锁是只能被单个线程占有还是可以被多个线程同时占有,可以分为共享锁和独占锁。

独占锁保证同一时间只能由一个线程持有锁,ReentrantLock就是一种独占锁。共享锁可以同时被多个线程持有,例如ReadWriteLock读写锁就是一种共享锁,对于一个资源它允许所有持有锁的线程可以同时进行读操作。

独占锁即排他锁是一种悲观锁,读写操作均只允许持有锁的唯一线程进行操作,这影响了并发性能,因为读操作并不会影响数据一致性。

共享锁是一种乐观锁,他放宽了加锁的条件,允许多个线程同时对一个资源进行读操作。

2.12.4 什么是可重入锁

我们知道当一个线程持有独占锁时,其他线程尝试获得锁会被暂时阻塞挂起。如果已经持有锁的线程可以再次请求获得该锁时,不会阻塞,可以重新获得锁,它就是可重入锁。也就是说如果线程获得了重入锁,它可以无限次(严格来说是有限次的)的进入被该锁锁住的代码。

举个例子:

当我们调用helloB()方法,会先获取内置锁然后进行输出,再调用helloA()方法,在调用前会再去获取内置锁,如果内置锁是不可重入的此时就会陷入无限阻塞。

但实际上内置锁是可重入锁。可重入锁的原理是锁内部维护一个标识,用于标识是哪个线程在持有锁,并关联一个计数器。当一个锁被某线程持有时,计数器为1 。如果持有锁的线程自己再次获得该锁,则计数器+1 。释放锁时计数器-1,当计数器为0时将锁内的线程标识置为null,此时唤醒其他阻塞的线程进行竞争获取该锁。

2.12.5 自旋锁

由于Java中每个线程都与操作系统中的线程一一对应,在一个线程尝试获取锁失败进入阻塞状态时,线程从用户状态变为内核状态,而获取到锁时又将其切换到用户状态唤醒线程。这一状态切换的过程是开销较大的,在一定程度上会影响并发性能。自旋锁则是线程尝试获取锁失败后不会立刻阻塞并切换到内核状态,在不放弃cpu使用权的情况下,会多次尝试获取锁(默认是10次,可以使用JVM参数: -XX:PreLockSpinsh进行设置),很有可能在几次尝试中能等到锁被释放而获得锁。如果在尝试指定次数后仍然没有获得锁,则进入阻塞状态挂起。由此看是利用CPU的占用时间换取线程阻塞与调度的开销,但是这些时间很可能会是被完全浪费掉了。

———————————————————————第二章笔记到此结束————————————————————————

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值