0%

Java并发基础总结

JMM

java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。为了实现了JVM的跨平台性,在向上提供了一系列的指令的同时,也提供了一些编程规则需要理解和遵守,比如Happens-Before原则,as-if-serial原则,主内存工作内存的概念等等。

编译器,处理器进行不同层次上的指令重排会对多线程编程造成一定的影响,对于一些不应该进行指令重排的场景下,Java编译器通过在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序,JMM把内存屏障指令分为如下四类:
在这里插入图片描述
从JDK5开始,Java使用新的JSR-133内存模型,其主要作用就是提供了happens-before原则,屏蔽掉了底层解决内存可见行问题的实现,编程者只要记住happens-before原则,并在理解这一原则下进行编程,happens-before原则为:

  • 程序顺序原则:一个线程中的每个操作,都happens-before于该线程中的任意后续操作;
  • 监视器锁规则:对一个锁的解锁,一定happens-before于随后对这个锁的读
  • volatile变量规则:对一个volatile域的,happens-before于任意后续对这个volatile域的.
  • 传递性:A happens-before B, B happens-before C, 那么 A happens-before C.

Happens-Before

1. 程序次序规则:

在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变!

2. 管程锁定规则:

就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

3. volatile变量规则:

就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

4. 线程启动规则:

在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

5. 线程终止规则:

在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。

6. 线程中断规则:

对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。

7. 传递规则:

这个简单的,就是happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。

8. 对象终结规则:

这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。但依然会对毫无关联的两个语句进行指令重排,比如 int a=1; int b = 2; 它们的先后顺序可能会不一样.

主内存和工作内存

JMM定义的内存变量的访问规则(这里的变量是指线程共享的变量),有了主内存和工作内存的概念。 Java虚拟机规定所有变量(非线程私有的变量)都存在主内存中,而线程私有的局部变量存在线程独有的工作内存中,这两个概念比较类似Java内存规范中的堆(主内存)和虚拟机栈(工作内存),只是比较类似!

解决主内存的中的变量和工作内存中的变量的同步的方式就是用volatile关键字。

Volatile关键字

  • 作用:

    • 保证了“共享变量”在多线程环境下的“可见性”.
  • 底层实现原则:

    • 对被volatile修饰的变量进行写操作的时候,JVM会向处理器发送一条#Lock前缀指令,这个指令的作用就是将对应缓存行的数据写回到被缓存的内存。
    • 一个处理器将缓存回写到内存<addr>中,那么其他处理器中对<addr>内存地址的缓存都会被标记成’失效’。(缓存一致性协议MESI:由嗅探技术实现,每个缓存行会有一个标示位,分别代表 :M(被修改),E(独占的), S(共享的), I(无效的), 若读取的缓存行是无效,那么会重新从内存读取)

Synchronized关键字

1. 作用:

对于 Synchronized 关键字而言,每一个Java对象都可以作为锁,具体表现为:

  • 普通Synchronized方法,锁是当前对象
  • 静态Synchronized方法,锁是当前类的Class对象
  • 对于Synchronized方法块,锁是括号里配置的对象

2. 对象头:

  • 普通对象的对象头占2个字,分别为:
    • Mark Word:存储了对象的HashCode和锁信息
    • Class Metadata Address:存储对象类型的数据指针
  • 数组类对象的对象头占3个字,除了上面两个还有一个:
    • Array Length: 数组的长度

Mark Word:

在这里插入图片描述
在这里插入图片描述

3. 锁的升级和对比:

https://www.cnblogs.com/pomer-huang/p/10965228.html
在这里插入图片描述
在讲重量级锁的调用的时候,可以说一下Java对管程的实现,即每一个对象都可以被视作一个MonitorObject,且维护着一个WaitSet,EntrySet,具体可以看 这个
在这里插入图片描述

4. 原子操作的实现:

  • 处理器实现院子操作:

    • 1.总线锁:

      • 处理器提供一个LOCK #信号,当一个处理器在总线上输出此信号时,其他处理器的请求将会被阻塞,此时处理器可以独享内存。缺点是,内存的不同地址之间其实不存在同步关系,这样会使得效率很低.
    • 2. 缓存锁

      • 利用缓存一致性协议(如MESI协议),和处理器提供的LOCK指令对指定内存上锁,完成了对共享资源操作的互斥。
      • 需要注意的是以下两个情况不能使用缓存锁:
        1. 数据无法写入到缓存中,或操作数据跨多个缓存行。
        2. 处理器不支持缓存锁,此时会使用总线锁.
    • Java实现原子操作:

      • CAS操作 ( Compare And Swap )

        通过调用处理器提供的CMPXCHG指令信实现,CAS的作用是:相等则交换。
    • CAS遇到的三大问题和解决方法:

      • ABA问题

        • 问题:CAS会先检查操作值是否发生变化,如果发生了A->B->A这种变化则发现不出来。
        • 解决方法:通过增加版本号的方式来解决: 1A->2B->3A
        • 循环时间开销大:

          • 问题:在循环尝试CAS时,会给CPU带来很大的执行开销。
          • 解决方法:如果jvm能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令可以延迟CPU的执行并且不会因此清空cpu的指令流水的预读取。
          • 问题:只能保证一个共享变量执行原子操作:
          • 解决方法:将多个共享变量打包成一个就行了。从JDK1.5开始,JDK提供了AtomicReference类来保证引用对象的原子性!

ReentrantLock与AQS

详情


start和run的区别

在这里插入图片描述

  • start方法是创建一个新的子线程并启动(调用run方法)
  • run方法只是Thread的一个普通方法的调用

线程的状态

1. 新建(New): 创建后尚未启动的线程的状态

2. 运行(Runnable):包含Running和Ready

3. 无限期等待(Waiting): 不会被分配CPU执行时间,需要被唤醒

  • 没有设置Timeout参数的Object.wait()方法
  • 没有设置Timeout参数的Thread.join()方法
  • LockSupport.part()

    4. 限期等待(timed waiting):在一定时间后会由系统自动唤醒

  • Thread.sleep()
  • 没有设置Timeout参数的Object.wait() 方法
  • 没有设置Timeout参数的Thread.join() 方法
  • LockSupport.parkNanos() 方法
  • LockSupport.parkUntil() 方法

5. 阻塞(Blocked): 等待获取排他锁

  • 就是在竞争锁的时候,被阻塞了(例如因Lock 或者synchronize 关键字产生的状态)

6. 终止(terminate):终止状态

在这里插入图片描述

wait/notify/notifyAll

这几个方法光知道是个啥,但理解的还不够深入,这里做一下总结。

1. wait/notify/notifyAll

在聊上面这个常见的方法之前,有必要先知道什么是管程。

当我们对临界区进行实现的时候,往往都是通过PV操作来实现的,但让程序员手动去做PV操作,很容易发生死锁。 所以为了方便编程,减少死锁出现的可能,我们希望能有一种数据结构或是软件模块来专门为我们提供对“临界区”的实现,这就是管程了~(但单单就说管程就是对临界区的实现是不准确的,继续往下看)

但仅仅是实现临界区还是不够的,比如,当线程A获取到锁了之后,进入了临界区,这个时候因为一些外部条件X, 而导致无法进行下去,这个时候就需要等待这个外部条件X的发生… 而假设这个外部条件X的发生是需要另一个线程B进入到当前的这个“临界区”中才能触发,而因为线程A已经处于临界区中了,所以线程B需要等待线程A退出临界区才能继续执行。。 于是。。就变成了线程A在等线程B,线程B在等线程A,死锁出现了。。

因此,解决临界区中的线程同步问题,也是管程需要实现的。

一个解决方案就是,在临界区中的线程A一旦发现自己想要的外部条件没有发生,而不能够继续进行下去了的时候,就主动释放掉当前获取的这个临界区的锁,然后让其他线程进入到这个临界区来触发这个“外部条件X”的发生。。 等到这个外部条件X发生了之后,再通知线程A(之前因这个条件而释放掉锁的那个线程)重新去竞争锁,继续执行临界区..

这个方法流程是不是很熟悉? 没错,这不就是wait和notify嘛。。

因此,管程的实现主要就是:

  • 临界区的实现
  • monitor 对象及锁的实现
  • 条件变量以及定义在 monitor 对象上的 wait,signal 操作的实现

然后就可以了解下Java对管程的实现了。

a. 对临界区的实现

Synchronized的同步块, ReentrantLock在lock和unlock期间的那段代码.. 都是对临界区的实现..

在Java中,每个对象都有两个池,锁(monitor)池和等待池

锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。

等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.

b. 条件变量以及定义在 monitor 对象上的 wait,signal 操作的实现

对于Synchronized,只实现了wait和signal操作…
如果想使用更细粒度的条件变量,来控制临界区内线程的同步,那么可以使用ReentrantLock来做..

ReentrantLock提供了Condition变量,作为条件变量,对应的方法是 condition.await() 和 condition.signal()

锁池和等待池

在Java中,每个对象都有两个池,锁(monitor)池和等待池

锁池: 假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。

等待池: 假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池。

wait原理:

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。
优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

必读:
sleep()和wait()方法与对象锁、锁池、等待池

Thread.yield()和Thread.sleep(0)

推荐阅读

wait和park的区别

我们在编程的时候必须能保证wait方法比notify方法先执行。如果notify方法比wait方法晚执行的话,就会导致因wait方法进入休眠的线程接收不到唤醒通知的问题。

而park、unpark则不会有这个问题,我们可以先调用unpark方法释放一个许可证,这样后面线程调用park方法时,发现已经许可证了,就可以直接获取许可证而不用进入休眠状态了。

LockSupport.park() 的实现原理是通过二元信号量做的阻塞,要注意的是,这个信号量最多只能加到1。我们也可以理解成获取释放许可证的场景。unpark()方法会释放一个许可证,park()方法则是获取许可证,如果当前没有许可证,则进入休眠状态,知道许可证被释放了才被唤醒。无论执行多少次unpark()方法,也最多只会有一个许可证。

另外,和wait方法不同,执行park进入休眠后并不会释放持有的锁。
并且,调用wait方法需要已经获取到锁,而park则不需要

wait的局限,以及Condition的出场

使用wait的一个前提就是在sync的同步块里,而这又导致了在同步块里的条件变量只有一个,尽管可以通过共享变量的方式来实现“需要多个条件变量的场合”,但这样不仅实现的复杂度高,而且也不是很高效。因此,为了让在同步块中使用更多样的条件变量(即对某一资源或者某一个事件的等待),ReentrantLock就提供了Condition这一个神器,一个Lock可以new出多个Condition,即多个等待队列。

所以,await/signal, 可以看成强化版的 wait/notify

参考链接

Q&A:

1. 信号量和条件变量的区别是什么?

  • 条件变量可以通过signal()唤醒队首阻塞线程,使用signalAll()来唤醒所有阻塞线程;而信号量只能通过release()唤醒队首阻塞线程.
  • 信号量可以初始化初始的值,但条件变量不可以,但条件变量+共享变量可以实现初始值大于0的信号量的功能。(个人理解成,条件变量的功能类似一个初始值为0的信号量 )。