回顾一下synchronized关键字,多线程编程的思路

  写过 JAVA 并发代码的同学对 synchronized 关键字一定是熟的不能再熟了,其基于对象头部的 monitor 实现了对代码块的加锁,使一段代码变为线程不可重入的。

  synchronized 与操作系统层的 lock 与 unlock 机制非常类似,多线程通过一个共享变量通信,这个共享变量标志着一段代码是否正在被一段线程执行着,如果已有线程在执行这段代码了,那么其它线程便等待在这个信号量上,直到其执行完毕并重置信号量。

  操作系统层级实现 lock/unlock 有两种方式,一是单核环境下通过禁止中断来防止时钟中断打断自己的执行,从而使 lock/unlock 之间代码的执行是不被打断的,单核环境下这也意味着上锁的代码不会有其它线程的重入,保证了上锁代码的线程排它性。

  但是这种模式仅适合单核处理器的情况,如果是多核处理器,即使本线程所在处理器禁止了中断,其它线程依然可以运行在其它处理器上来执行上锁的代码,代码还是可重入的。所以多线程情况下我们使用内存中的共享变量来进行线程间的通信,信号量像一扇门,线程进去时关门,出来时开门,只有门是开着的线程才可以进入上锁代码区。这样一来我们必须保证多线程情况下每个线程对共享变量“门”的操作的正确性。对于 X86 架构来说,可以借助大名鼎鼎的 test&set 原语来对共享变量进行操作,因为这是处理器级别的原语,执行过程是不可打断的,保证了单核心环境下的线程排它性。再结合内存的总线锁保证多核对 “门” 的访问的排它性,即可保证多线程环境下对锁操作的线程排它性。

  使用“门”来保证线程排它性也有两种方式,一种是繁忙等待,一个线程发现“门”是关着的,便在while中不断检查门的状态只到其开启,显然这种方案会导致大量的计算资源浪费在循环中。

  另一种方式是非繁忙等待,我们为“门”设置一个等待队列,线程发现“门”是关闭的则进入阻塞状态,并加入等待队列。等待队列中的线程因为是阻塞的,不会在等待过程中占用计算资源,处理器可以被解放出来去做更多有意义的事情。“门”内的线程执行“开门”动作时,从等待队列中挑一个或几个线程唤醒,让他们继续执行或竞争。大部分情况下我们采用的都是非繁忙等待的方式。

  将上述描述中的门换做对象头的monitor,lock/unlock 换做更高抽象层级的moitor-enter/monitor-exit,则可以直接用来描述 synchronized 的原理了。当然,这些高层的抽象的实现是基于上述底层的抽象的。

  对于线程安全的定义,因为其描述主体的不同而不同。比如一个变量是线程安全的,还是一段代码是线程安全的,它们的定义一定是不同的。《Java并发编程》中对线程安全的定义也只是在多线程情况下可以保证程序的正确性,但是正确性的概念却非常模糊(可能因为我看的是翻译版?)。

  没找到一个明确的定义,我只能尝试总结一下个人在进行多线程编程时的思路。

  在进行多线程编程时,如果线程间不需要进行通信,各个线程独立执行,彼此间没有共享的数据,则不需要考虑线程安全问题,它们本身就是一些彼此独立的单线程罢了。

  但是一旦线程间需要协作,则必须通过共享的数据进行通信,也就产生了数据依赖。我们必须保证无序执行的线程们对这些依赖的访问的正确性。基于对共享数据访问正确性的保证,推演出线程间的互斥关系。

     同时在定义共享数据时,我们一定是基于A与B是如何基于共享数据协作来定义的,那么这便是A与B线程的同步关系。

  这里面最难的是互斥关系的定义,同步关系是我们对线程协作方式的设计,而互斥关系是要保证多线程对共享数据访问的正确性。因此互斥关系需要我们从程序动作的依赖关系,语言/操作系统甚至是硬件层的特性来综合的考虑。

  比如一个“门”的实现,我们对门的动作会有三种:1.检查门的状态,2.开门,3.关门。

  很显然,关门必须时线程排它的,如果有两个线程同时关门,则无法保证加锁区域的线程排它性。而开门不需要,因为只有门内的线程才可以开门,在我们的设计中同一时间只会有一个线程在门内。

     检查门的状态与开门是存在依赖关系的,因为我们必须基于1的结果来判断3是否执行。所以1与3应该是一组线程排它的动作。一个线程一旦执行了1,那么其它线程对1与3都不能执行。

  如果A执行1,然后去开门,门还未被打开时间片用完被挂起,此时B来检查门的状态,发现门还是开启的,那么A与B会同时进入同步代码区,程序并没有按我们既定的语义正确执行。

  所以互斥关系是1与2应该被我们设计成一个操作4:检查门的状态,如果是开启的则关门进入同步代码区,否则移步到门的等待队列并阻塞。并且对与动作4,各线程间是互斥的。

  1与2应该是一个原子操作,必须全部执行完,并且执行过程中不能有其它线程打断和进入造成共享数据的污染(原子性)。

  互斥关系被推演出来了,我们再考虑一个问题,如果A已经执行了动作4检查并关门进入了同步代码区,但是门的状态驻留在CPU1的写缓冲区中,没有刷新到缓存,那么此时CPU2执行线程B则会发现门还是开的,AB同时进入了同步代码区。这是基于处理器的硬件结构来考虑的,因为 B 执行操作4对于 A 执行操作4是有数据依赖的(反过来A对B也有),所以不管A还是B,我们必须保证其对操作4的执行结果一定对另一方可见才能保证程序的正确运行。X86架构下的读写屏障可以帮我们实现这一点,在JAVA中体现为volatile或synchronized。(可见性)

  我们再看开门的动作,在代码上体现为 1.开门 --》2.唤醒等待队列的线程。代码写的很漂亮,但执行时很可能不是这样执行的。编译器在编译代码时,因为开门/唤醒等待队列中的线程不存在数据依赖关系,因此编译器很可能将其顺序打乱,因为对于单线程来讲先1还是先2并不影响单线程的执行结果。对于处理器来说也一样,等待队列所在的缓存如果处于busy,为了提高流水线的吞吐量减少cache wait,处理器很可能先将 2 送入流水线。

  虽然单线程情况下无论先执行1还是先执行2,结果都一样。单对于多线程来说情况并没有这么乐观,用等待队列中线程的视角看这个线程,它仿佛是个神经病,到底先执行1还是先执行2根本让人摸不透。如果先执行2,等待的线程被唤醒,此时1还没执行,门还是关的,于是被唤醒的线程又去睡觉了。此时再执行1,门虽然开了,却没有线程试图进入同步代码区,以为它们都在睡觉,并且是没有人叫醒的那种。

  所以动作1,2在单线程情况下虽然不存在数据依赖,但是在多线程情况下存在依赖。2的执行必须依赖1的执行才能使程序正确的运行,但是编译器和处理器却不能感知到这一点,这需要我们自己去保证,1执行完2才可以执行。处理器层面实现这一点还是靠内存屏障,Java层面则还是volatile和synchronized(有序性)

  再看一下我们设计的流程:

  1. 我要编写一个多线程的程序,需要线程间的协作。

  2. 从功能层面设计各个线程的协作方式,从而定义它们通信使用的共享变量。

  3. 为了保证对多线程共享变量访问的正确性,让它们按我们既定的逻辑进行协作,推演线程间的互斥关系。

  4. 推演互斥关系的过程中我们发现哪些动作是不可分割的,哪些动作是互斥的。而依据就是多线程乱序的执行这些动作,如果可分割或者不互斥,那么多线程不能按我们既定的逻辑进行协作。

  5. 我们还需要找到多线程情况下,动作间的依赖关系,保证多线程情况下被依赖的动作对其它线程的可见性和有序性。

  关键词:同步互斥关系,多线程环境下动作的数据依赖,动作的原子性。

  其中,互斥关系的推演以及原子性的定义是最复杂的,题目多解,没有固定解法,特别考验内功与经验。

原文地址:https://www.cnblogs.com/niuyourou/p/12406070.html

时间: 2024-11-09 05:13:21

回顾一下synchronized关键字,多线程编程的思路的相关文章

多线程编程学习一(Java多线程的基础)

一.进程和线程的概念 进程:一次程序的执行称为一个进程,每个 进程有独立的代码和数据空间,进程间切换的开销比较大,一个进程包含1—n个线程.进程是资源分享的最小单位. 线程:同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小,线程是CPU调度的最小单位. 多进程:指操作系统能同时运行多个任务(程序). 多线程:指同一个程序中有多个顺序流在执行,线程是进程内部单一控制序列流. 二.多线程的优势 单线程的特点就是排队执行,也就是同步.而多线程能最大限度的利用CP

.NET多线程编程

在.NET多线程编程这个系列我们讲一起来探讨多线程编程的各个方面.首先我将在本篇文章的开始向大家介绍多线程的有关概念以及多线程编程的基础知识;在接下来的文章中,我将逐一讲述.NET平台上多线程编程的知识,诸如System.Threading命名空间的重要类以及方法,并就一些例子程序来作说明. 引言 早期的计算硬件十分复杂,但是操作系统执行的功能确十分的简单.那个时候的操作系统在任一时间点只能执行一个任务,也就是同一时间只能执行一个程序.多个任务的执行必须得轮流执行,在系统里面进行排队等候.由于计

多线程编程-- part 3 多线程同步->synchronized关键字

多线程同时访问一个资源,可以会产生不可预料的结果,所以为这个资源加锁,访问资源的第一个线程为其加锁后,其他线程便不能在使用那个资源,直到锁被解除. 举个例子: 存款1000元,能取出800的时候我就取800,当我同时用两个线程调用这个取钱操作时,有时可以取出1600元 static class HelloRunable implements Runnable{ private int money = 1000; //取出800元 int getMoney() { System.out.print

【转】Java多线程编程中易混淆的3个关键字( volatile、ThreadLocal、synchronized)总结

概述 最近在看<ThinKing In Java>,看到多线程章节时觉得有一些概念比较容易混淆有必要总结一下,虽然都不是新的东西,不过还是蛮重要,很基本的,在开发或阅读源码中经常会遇到,在这里就简单的做个总结. 1.volatile volatile主要是用来在多线程中同步变量. 在一般情况下,为了提升性能,每个线程在运行时都会将主内存中的变量保存一份在自己的内存中作为变量副本,但是这样就很容易出现多个线程中保存的副本变量不一致,或与主内存的中的变量值不一致的情况.而当一个变量被volatil

java多线程编程之使用Synchronized关键字同步类方法

最简单的方法就是使用synchronized关键字来使run方法同步,看下面的代码,只要在void和public之间加上synchronized关键字 复制代码 代码如下: public synchronized void run(){     } 从 上面的代码可以看出,只要在void和public之间加上synchronized关键字,就可以使run方法同步,也就是说,对于同一个Java类的 对象实例,run方法同时只能被一个线程调用,并当前的run执行完后,才能被其他的线程调用.即使当前线

JAVA并发编程3_线程同步之synchronized关键字

在上一篇博客里讲解了JAVA的线程的内存模型,见:JAVA并发编程2_线程安全&内存模型,接着上一篇提到的问题解决多线程共享资源的情况下的线程安全问题. 不安全线程分析 public class Test implements Runnable { private int i = 0; private int getNext() { return i++; } @Override public void run() { // synchronized while (true) { synchro

Java多线程编程——volatile关键字

(本篇主要内容摘自<Java多线程编程核心技术>) volatile关键字的主要作用是保证线程之间变量的可见性. package com.func; public class RunThread extends Thread{ private boolean isRunning = true; // volatile private boolean isRunning = true; public boolean isRunning() { return isRunning; } public

Java对象锁和类锁全面解析(多线程synchronized关键字)

最近工作有用到一些多线程的东西,之前吧,有用到synchronized同步块,不过是别人怎么用就跟着用,并没有搞清楚锁的概念.最近也是遇到一些问题,不搞清楚锁的概念,很容易碰壁,甚至有些时候自己连用没用对都不知道. 今天把一些疑惑都解开了,写篇文章分享给大家,文章还算比较全面.当然可能有小宝鸽理解得不够深入透彻的地方,如果说得不正确还望指出. 看之前有必要跟某些猿友说一下,如果看一遍没有看明白呢,也没关系,当是了解一下,等真正使用到了,再回头看. 本文主要是将synchronized关键字用法作

java基础回顾(五)线程详解以及synchronized关键字

本文将从线程的使用方式.源码.synchronized关键字的使用方式和陷阱以及一些例子展开java线程和synchronized关键字的内容. 一.线程的概念 线程就是程序中单独顺序的流控制.线程本 身不能运行,它只能用于程序中. 二.线程的实现 线程的实现有两种方式: 1.继承Thread类并重写run方法 2.通过定义实现Runnable接口的类进而实现run方法 当用第一种方式时我们需要重写run方法因为Thread类里的run方法什么也不做(见下边的源码),当用第二种方式时我们需要实现