Java多线程基础知识总结(绝对经典)

 

一、前言

线程安全是并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要原因有两点,一是存在共享数据,二是存在多条线程操作共享数据。当存在多个线程操作共享数据时,需要保证同一时刻有且仅有一个线程在操作共享数据,其它线程必须等到该线程处理完数据之后才能进行,这种方式有个高尚的名词叫互斥锁,即能达到互斥访问的目的的锁。在Java中关键字synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或某个代码块。同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化被其它线程所看到(保证可见性,完全可以替代volatile),这点很重要。

二、悲观锁与乐观锁

1、悲观锁

悲观锁在一个线程进行加锁操作后使得该对象变为该线程的独有对象,其它的线程都会被悲观锁阻拦在外,无法操作。

悲观锁的缺陷:

  • 一个线程获得悲观锁后其它线程必须阻塞。
  • 线程切换时要不停的释放锁和获取锁,开销巨大。
  • 当一个低优先级的线程获得悲观锁后,高优先级的线程必须等待,导致线程优先级倒置,synchronized锁是一种典型的悲观锁。

2、乐观锁

乐观锁认为对一个对象的操作不会引发冲突,所以每次操作都不进行加锁,只是在最后提交更改时验证是否发生冲突,如果冲突则再试一遍直到成功为止,这个尝试的过程被称为自旋。乐观锁其实并没有加锁,但乐观锁也引入了诸如ABA、自旋次数过多等问题。

三、CAS

1、JDK1.5,java.util.concurrent

在JDK1.5之前,Java中所有锁都是重量级的悲观锁,1.5中引入了java.util.concurrent包,这个包中提供了乐观锁的使用,而整个JUC包实现的基石就是CAS操作。

2、CAS的全称是Compare And Swap

CAS的全称是Compare And Swap,即比较交换,其算法核心思想是:

执行函数:CAS(V,E,N)

3个参数:

  • V表示要更新的变量
  • E表示预期值
  • N表示新值

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作,原理图如下:

3、代码实例

以AtomicInteger的修改为例查看使用CAS时如何无锁并安全的修改某个值的:

    public final int getAndUpdate(IntUnaryOperator updateFunction) {
        int prev, next;
        do {
            prev = get();
            next = updateFunction.applyAsInt(prev);
        } while (!compareAndSet(prev, next));
        return prev;
    }

先获得要修改的原值prev和要改成的新值next,当使用CAS替换新值不成功时,自旋,重新获得原值和新值再试一次直到成功为止。

这段代码中可以发现两个问题:

  • CAS操作必须是原子性的,即操作中间无法被打断。
  • 获取原值时要保证这个原值对本线程可见。

CAS其实是调用了JNI,使用本地方法来保证原子性。

JNI(Java native interface),通过使用Java本地接口书写程序,可以确保代码在不同的平台上方便移植。

4、CAS带来的问题

(1)ABA问题

CAS操作的流程为:

  • 读取原值。
  • 通过原子操作比较和替换。

虽然比较和替换是原子性的,但是读取原值和比较替换这两步不是原子性的,期间原值可能被其它线程修改。

ABA问题有些时候对系统不会产生问题,但是有些时候却也是致命的。

ABA问题的解决方法是对该变量增加一个版本号,每次修改都会更新其版本号。JUC包中提供了一个类AtomicStampedReference,这个类中维护了一个版本号,每次对值的修改都会改动版本号。

(2)自旋次数过多

CAS操作在不成功时会重新读取内存值并自旋尝试,当系统的并发量非常高时即每次读取新值之后该值又被改动,导致CAS操作失败并不断的自旋重试,此时使用CAS并不能提高效率,反而会因为自旋次数过多还不如直接加锁进行操作的效率高。

(3)只能保证一个变量的原子性

当对一个变量操作时,CAS可以保证原子性,但同时操作多个变量时CAS就无能为力了。

可以封装成对象,再对对象进行CAS操作,或者直接加锁。

四、多线程锁的升级原理是什么?

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁、重量级锁。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级是单向的,只能升级不能降级。

1、无锁

没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其它修改失败的线程会不断重试直到修改成功。

无锁总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,CAS是无锁技术的关键。

2、偏向锁

对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。

偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停偏向锁的线程,然后判断锁对象是否处于被锁定状态,如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁。

如果线程处于活动状态,升级为轻量级锁的状态

3、轻量级锁

轻量级锁是指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从er提升性能。

当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定次数时,轻量级锁边会升级为重量级锁,当一个线程已持有锁,另一个线程在自旋,而此时第三个线程来访时,轻量级锁也会升级为重量级锁。

注:自旋是什么?

自旋(spinlock)是指当一个线程获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

4、重量级锁

指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

重量级锁通过对象内部的监听器(monitor)实现,而其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。

5、锁状态对比

  偏向锁 轻量级锁 重量级锁
使用场景 只有一个线程进入同步块 虽然很多线程,但没有冲突,线程进入时间错开因而并未争抢锁 发生了锁争抢的情况,多条线程进入同步块争用锁
本质 取消同步操作 CAS操作代替互斥同步 互斥同步
优点 不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是比对ThreadId) 不会阻塞 不会空耗CPU
缺点

适用场景太局限。若竞争产生,会有额外的偏向锁撤销的消耗

长时间获取不到锁空耗CPU 阻塞,上下文切换,重量级操作,消耗操作系统资源

6、锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,别切不会被其它线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

五、Synchronized的特性

1、可重入性

synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁;

可重入的好处:

(1)可以避免死锁;

(2)可以让我们更好的封装代码;

synchronized是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。

2、不可中断性

一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;

synchronized 属于不可被中断;

Lock lock方法是不可中断的;

Lock tryLock方法是可中断的;

六、Synchronized保证了原子性、可见性、有序性

1、Synchronized保证原子性

public class Test {
    private static int number = 0;
    private static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        Runnable increment = () -> {
            for (int i = 0;i<1000;i++){
                number++;
            }
        };


        List<Thread> list = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(increment);
            t.start();
            list.add(t);
        }

        for (Thread t : list) {
            t.join();
        }

        System.out.println("number = " + number);
    }
}

Synchronized保证原子性

Runnable increment = () -> {
    for (int i = 0;i<1000;i++){
        synchronized (obj){
            number++;
        }
    }
};

2、Synchronized保证可见性

public class Test1 {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
                
            }
        }).start();

        Thread.sleep(2000);

        new Thread(()->{
            flag = false;
            System.out.println("线程修改了变量的值为false");
        }).start();
    }
}

volatile即可解决这个问题!

public static volatile boolean flag = true;

Synchronized保证可见性

Synchronized保证可见性的原理,执行Synchronized时,会对应lock原子操作会刷新工作内存中共享变量的值。

3、Synchronized保证有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

七、synchronized的三种应用方式

1、修饰普通方法

作用于当前方法加锁,进入同步代码前要获得当前实例的锁。

(1)synchronized保证线程安全

(2)synchronized什么情况下无法保证线程安全

(3)这时synchronized修饰在静态方法上,就可以解决这个问题了。

start()、run()、join()的区别:

start():线程不会立即启动。相当于是在就绪队列里面;
run():启动线程;
join():主要作用是同步,它可以使得线程之间的并行执行变为串行执行。

join方法的作用:

在A线程中调用了B线程的join方法,表示只有当B线程执行完毕后,A线程才能继续执行。注意调用的join方法是没有传参的,join方法其实可以传递一个参数给它,如果A线程中掉用B线程的join(10),则表示A线程会等待B线程执行10毫秒,10毫秒过后,A、B线程并行执行。需要注意的是,jdk规定,join(0)的意思不是A线程等待B线程0秒,而是A线程等待B线程无限时间,直到B线程执行完毕,即join(0)等价于join()。

2、修饰静态方法

作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。

但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。

3、修饰同步代码块

指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁。

除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:

从代码可以看出,将synchronized作用域一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其它线程正持有该对象锁,那么新到的线程必须等待,这样也就保证了每次只有一个线程执行i++操作。当然除了使用instance作为对象外,还可以使用this对象(代表当前实例)或者当前类的class对象作为锁,如下:

//this,当前实例对象锁
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

//class对象锁
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

八、理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

内存填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

Java头对象是是实现synchronized锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字节来存储对象头(数组是三个字节),其主要结构是由Mark Word和Class Metadata Address组成,其结构说明如下表:

头对象结构 说明
Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

分析一下重量级锁也就是synchronized对象锁,锁标识位为10,其中指针指向的是monitor对象的起始地址。每个对象都存在这一个monitor与之关联,对象与其monitor之间的关系有多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

由此可见,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁边是通过这种方式获取的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify、notifyAll、wait等方法存在于顶级对象Object中的原因。

九、synchronized与等待唤醒机制

所谓等待唤醒机制是指notify、notifyAll、wait方法,在使用者3个方法时,必须处于synchronized中,否者就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify、notifyAll、wait依赖于monitor对象,在前面的分析中,我们知道monitor存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

需要注意的一点,wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),知道有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠而不释放锁。

同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized方法中执行结束后才自动释放锁。

1、notify() 和 notifyAll() 有什么区别?

先解释两个概念:

等待池:假设一个线程调用了wait方法,线程就会释放该对象的锁,进入到该对象的等待池

锁池:只有获得了对象的锁,线程才会执行对象的synchronizeed代码,对象的锁每次只有一个线程可以获得,其它线程只能在锁池中等待

notify()方法随机唤醒对象等待池中的一个线程,进入锁池。

notifyAll()唤醒对象的等待池中的所有线程,进入锁池。

2、execute()和submit()有什么区别?

线程任务分两类,一类是实现了runnable接口,一类是实现了callable接口。

execute(Runnable x)没有返回值,可以执行任务,但无法判断任务是否成功完成,实现runnable接口

submit(Runnable x)返回一个future。可以用这个future来判断任务是否成功完成,实现Callable接口

3、sleep() 和 wait() 有什么区别? 

(1)sleep()是thread类的静态本地方法

wait()是Obejct类的成员本地方法

(2)sleep()方法可以在任何地方使用

wait()方法只能在同步方法或同步代码块中使用

(3)sleep()会休眠当前线程指定时间,释放CPU资源,不释放对象锁,休眠时间到自动苏醒继续执行

wait()方法放弃持有的对象锁,进入等待队列,当该对象被调动notify()或notifyAll()方法后才有机会竞争获取对象锁,进行运行状态

(4)均需捕获interruptedException异常

十、synchronized 和 volatile 的区别是什么?

1、synchronized表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其它线程;volatile表示变量在CPU寄存器中是不确定的,必须从内存中读取,保证多线程环境下变量的可见性和有序性。

2、synchronized可以作用于方法、变量;volatile只能作用于变量。

3、synchronized可以保证线程间的有序性、原子性和可见性;volatile只保证了可见性和有序性,无法保证原子性。

4、synchronized线程阻塞,volatile线程不阻塞。

上一篇:死锁、活锁、饿死和阻塞的个人理解

下一篇:Java中守护线程的总结

猜你喜欢

转载自blog.csdn.net/guorui_java/article/details/107599455