java 线程和锁(译)

线程和锁
翻译一篇文章,java虚拟机规范里讲线程和锁部分。虽然是1.6版本。但是基本的一些概念还是差不多的。中间有一些篇幅看着很拗口,可能就是整体机器翻译的,如果看不懂应该也很正常。后面应该有时间再单独学习某一部分写文章记录。
原文链接地址:
https://docs.oracle.com/javase/specs/jvms/se6/html/Threads.doc.html

8.1 术语和架构

一个程序中任何位置的变量都需要被存储。这不仅包括类变量(class variables)、实例变量(instance variables),也包括数组。主内存(main memory)中存储的变量是所有线程共享的。但是一个线程是无法访问另一个线程的参数和局部变量的,不管参数和局部变量是被认为驻留在共享主内存中,还是驻留在拥有它们的线程的工作内存(working memory)中。

每个线程拥有一个工作内存用来存储自己使用和赋值的变量副本。线程执行程序过程中是操作的这些工作副本。主内存存储每个变量的原版(master copy)。当一个线程转储其工作副本变量数据到主内存时需要遵循一定规则,反之亦然。

主内存还包含锁,每一个对象都会分配一把锁,线程间需要竞争获取锁。

就本章而言,一个线程可以执行use, assign, load, store, lock, and unlock操作,主内存可以执行read, write, lock, and unlock。这些操作都是原子操作*。*

use、assign是一对紧密相连的操作在线程的执行引擎和工作内存之间。lock、unlock是一对紧密相连的操作在线程的执行引擎和主内存之间。但是在主内存和线程工作内存间数据传输并不需要紧密相连的连贯操作。当数据从主内存复制到工作内存中时,一定会发生两个动作:主内存read操作,相关线程工作内存load操作。数据从工作内存传说到主内存,一定会发生两个动作:工作内存store操作,主内存write操作。read和load操作,store和write操作可能相隔一段时间。主内存和工作内存之间需要一定的传输时间。每一个事物花费的时间也不尽相同。因此,一个线程引发的变量修改可能会以不同的发生顺序被另一个线程所感知。然而对每一个变量,主内存中代表任何一个线程的操作都是按照与该线程的相应操作相同的顺序执行的。

一个线程根据当前正在执行的程序语法,发出一系列use、assign、lock、unlock操作。JVM底层实现会执行合适的load、store、read、write操作,前提会遵循一系列约束,后面会讲到。底层实现正确地遵循这些规则,程序员遵循某些其他编程规则,那么线程和共享变量之间的数据传送就会变的非常可靠。这些规则被设计得足够“严格”,可以实现这一点,但足够“宽松”,可以让硬件和软件设计者有相当大的自由度,通过寄存器、队列和缓存等机制来提高速度和吞吐量。

以下是每个操作的详细定义:

use操作(由线程执行)将变量的线程工作副本的内容传输到线程的执行引擎。每当线程执行使用变量值的虚拟机指令时,都会执行此操作。

assign操作(由线程执行)将一个值从线程的执行引擎转移到线程的变量工作副本中。每当线程执行分配给变量的虚拟机指令时,都会执行此操作。

read操作(由主内存执行)将变量主副本的内容传输到线程的工作内存,供以后的加载操作使用。

load操作(由线程执行)将通过读取操作从主存传输的值放入线程的变量工作副本中。

store操作(由线程执行)将变量的线程工作副本的内容传输到主内存,供以后的写入操作使用。

write操作(由主内存执行)将通过存储操作从线程的工作内存传输的值放入主内存中变量的主副本。

lock操作(由与主内存紧密同步的线程执行)会导致线程获取特定锁的一个声明。

unlock操作(由与主内存紧密同步的线程执行)会导致线程释放特定锁上的一个声明。

线程和变量操作包括一系列use, assign, load, and store操作。主内存为每个load操作执行read操作,为store操作执行write操作。线程和锁之间操作包括lock和unlock。一个线程所有的可见行为都是围绕变量和锁操作。

8.2 执行顺序和一致性

执行顺序规则约束某些事件可能发生的顺序。执行动作之间的关系有四个一般约束:

1、任何一个线程执行的操作都是完全有序的;也就是说,对于线程执行的任何两个操作,一个操作在另一个操作之前。

2、主内存对任何一个变量执行的操作都是完全有序的;也就是说,对于主存储器对同一变量执行的任何两个操作,一个操作在另一个操作之前

3、主内存对任何一个锁执行的操作都是完全有序的;也就是说,对于主存储器在同一个锁上执行的任何两个操作,一个操作在另一个操作之前

4、不允许一个操作自行发生。

最后一条规则可能看起来微不足道,但为了完整性,它确实需要单独明确地说明。如果没有这条规则,就有可能由两个或多个线程提出一组操作,这些操作之间的优先级关系将满足所有其他规则,除了第4条。

线程和线程之间不之间交换,通过主内存进行通信。线程的操作和主内存的操作之间的关系受到三种方式的约束:

每个lock或unlock都是由一些线程和主存储器联合执行的。

线程的每个load与主内存的read唯一配对,使得load跟随read操作。

线程的每个store与主内存的write操作唯一配对,使得写入操作跟随存储操作。

以下部分中的大多数规则进一步限制了某些操作的发生顺序。规则可以规定一个操作必须在其他操作之前或之后。注意,这种关系是可传递的:如果操作A必须在操作B之前,而B必须在C之前,那么A必须在C前面。程序人员必须记住,这些规则是对操作顺序的唯一约束;如果没有规则或规则组合意味着操作A必须在操作B之前,那么Java虚拟机实现可以在操作A之前自由地执行操作B,或者可以与操作A同时执行操作B。这种自由可能是良好性能的关键。相反,虚拟机实现也不需要完全利用所赋予的上面所说的自由。

在接下来的规则中,“B必须介入A和C之间”这句话意味着动作B必须在动作A之后,在动作C之前。

8.3 关于变量的规则

设T是一个线程,V是一个变量。T相对于V执行的操作存在某些限制:

  • 只有当根据标准执行模型(standard execution model)由T执行程序时,才允许使用或分配V。例如,V作为+运算符的操作数的出现要求对V进行use操作;V作为赋值运算符=的左侧操作数的出现要求发生assign运算。给定线程的所有use和assign操作都必须按照线程执行的程序指定的顺序发生。如果以下规则禁止T执行所需的use作为其下一个操作,则T可能需要先执行load才能取得继续。
  • T对V的store操作必须介于T对V的assign和T对V后续load之间。(线程不允许丢失最近的赋值。)
  • T对V的assign操作必须介于T对V的load或store与T对V后续store之间(线程不允许无故将数据从其工作内存写回主内存)
  • 创建线程后,必须先对变量执行assign或load操作,然后再对该变量执行use或store操作(一个新线程的工作空间是空的)
  • 创建变量后,每个线程都必须对该变量执行assign或load操作,然后才能对该变量进行use或store操作(一个新变量只在主内存中创建,最初不在任何线程的工作内存中)

只要遵守第8.3、8.6和8.7节中的所有约束,任何线程都可以在任何时候根据实现逻辑对任何变量发出加载或存储操作。

主内存执行read和write操作也有一定的限制:

  • 对于任何线程T对变量V在工作副本执行的每一次load操作,主内存对V的主副本必须有相应的前一次read操作,并且load操作必须将相应read操作传输的数据放入工作副本。

  • 对于任何线程T对其变量V的工作副本执行的每个store操作,主内存必须在V的主副本上执行相应的write操作,并且write操作必须将相应store操作传输的数据放入主副本。

  • 设动作A是线程T对变量V进行的加载或存储操作,动作P是主存对变量V进行的相应的读写操作。类似地,设动作B是线程T对同一变量V进行的其他加载或存储操作,动作Q是主存对变量V进行的相应的读写操作。如果A在B之前,则P必须在Q之前(主内存代表线程对任何给定变量的主副本执行的操作与线程请求的顺序完全相同)。

    请注意,最后一条规则仅适用于线程对同一变量执行的操作。对于volatile 变量有一个更严格的规则(§8.7)

8.4 double和long类型变量的非原子处理

如果一个double或long类型变量没有被声明为volatile,那么出于加载、存储、读取和写入操作的目的,它被视为两个变量,每个变量32位。只要逻辑上需要其中一个操作,就会执行两个这样的操作,每个32位的一半执行一个操作。Java语言规范没有定义将double或long类型变量的64位编码为两个32位量的方式,以及对变量的一半进行运算的顺序。

这一点很重要,因为实际的主内存可以将double或long变量的读取或写入作为两个32位读取或写入操作来处理,这两个操作可能在时间上分开,其他操作也在其间。因此,如果两个线程同时将不同的值分配给同一个共享的非volatile类型double或long类型变量,则该变量的后续使用可能会获得一个值,该值不等于所分配的值中的任何一个,而是两个值的具体混合结果。

实现可以自由地将双精度值和长精度值的加载、存储、读取和写入操作实现为64位原子操作;事实上,这是强烈鼓励的。由于目前流行的微处理器无法在64位数量上提供有效的原子内存事务,该模型将它们分成32位的两部分。对于Java虚拟机来说,将单个变量上的所有内存事务定义为原子会更简单;这个更复杂的定义(分成两个变量)是对当前硬件实践的务实让步。将来这种让步可能会被淘汰。同时,提醒程序人员要显式同步(explicitly synchronize)对共享的double和long变量进行访问。

8.5 关于锁的规则

设T是一个线程,L是一个锁。对L执行的操作有一定的约束条件:

  • 一次只允许一个线程请求一个锁;此外,一个线程可以多次获得相同的锁,并且在执行了匹配数量的解锁操作之前不会放弃对锁的所有权。
  • 线程不允许解锁不属于它的锁

对于锁,所有线程执行的lock和unlock操作都是按照一定的顺序执行的。这个总顺序必须与每个线程操作的总顺序一致

8.6 锁和变量交互的规则

设T为任意线程,V为任意变量,L为任意锁。T对V和L执行的运算有一定的约束:

  • 如果线程要对任何锁执行解锁操作,它必须首先将其工作内存中的所有赋值复制回主内存。
  • 锁操作的行为就像从线程的工作内存中清除所有变量一样,之后线程必须自己分配变量,或者从主存中重新加载副本。

8.7 volatile变量规则

如果变量被声明为volatile,那么额外的约束将应用于每个线程的操作。设T为线程,设V和W为volatile变量

  • use之前必须先load,load前一个操作必须是use
  • assign下一个操作必须是store,store前一个操作必须是assign。
  • 代表线程对volatile变量的主副本进行的操作由主内存按照线程请求的顺序执行

8.8 预知的存储操作

如果没有将变量声明为volatile,那么前面几节中的规则将稍微放宽,允许store操作比其他允许的操作更早发生。这种放宽的目的是在保留正确程序的语义基础上,允许优化编译器执行某些类型的代码重排,但是可能会在执行内存操作时没有正确被同步执行的程序发现。

假设一个由T (V)存储的操作将按照前几节的规则遵循T (V)的特定赋值,没有中间的加载或T (V)赋值,那么该store操作将把赋值操作放入线程工作内存的值发送到主内存。如果遵守以下限制,则特殊规则允许存储操作实际发生在分配操作之前:

  • 如果发生store操作,则assign必然会发生
  • store和assign之间没有lock操作介入
  • store和assign之间没有load操作
  • 在当前对变量的store和assign之间没有其它的store操作
  • store操作将放入线程T的工作内存的assign的值发送到主内存

最后一个属性启发我们称这种早期的存储操作是有先见之明的:它必须提前知道,它应该遵循的assign将存储什么值。在实际中,经过优化编译的代码会提前计算这些值,提前存储它们(例如,在进入循环之前),并将它们保存在工作寄存器中,以便日后在循环中使用。

8.8 讨论

锁和变量之间的任何关联都是纯粹的惯例。从概念上讲,锁定任何锁都会从线程的工作内存中刷新所有变量,而解锁任何锁则会强制将线程已分配的所有变量写入主内存。锁是锁的对象或类。在某些应用程序中,总是在访问对象的任何实例变量之前锁定对象可能是合适的。synchronized方法就可以很好的实现这种方式。在其它一些应用中,可能使用单例锁来同步访问一系列对象。如果线程在lock之后和该锁的相应unlock之前使用特定共享变量,则线程将在锁定操作之后(如有必要)从主内存读取该变量的共享值,并将在unlock之前将最近给该变量的assign值复制回主内存。这与锁的互斥规则相结合,足以保证通过共享变量将值从一个线程正确地传输到另一个线程。

volatile变量的规则有效地要求线程在每次使用或赋值volatile变量时精确地访问一次主内存,并且精确地按照线程执行语义规定的顺序访问主存。然而,这些内存操作相对于对非volatile变量的读写操作是没有顺序的。

8.10 例:可能的交换

有下面类

class Sample {
    int a = 1, b = 2;
    void hither() {
    	a = b;
    }
    void yon() 
    	b = a;
    }
}

假设有两个线程,一个线程调用hither方法,另一个线程调用yon方法。操作集是什么?排序约束是什么?

让我们考虑一下调用hither的线程。根据规则,这个线程必须执行对b的use,然后执行对a的assign。这是执行对方法调用的最低要求。

现在,线程对变量b的第一个操作不是use。可能是assign或load。对b的assign不能发生,因为程序文本没有调用这样的assign操作,所以需要load变量b。线程的这个load操作反过来又需要主存对b进行先前的read操作。

线程可以选择在assign之后store变量a的值。如果要存储,那么store操作又需要对主存进行write操作。

调用yon 方法的线程和上面的线程同理,只不过变量操作a和b交换了下。

整个操作过程如下图所示:

箭头方向代表执行顺序。

主内存中执行顺序是怎样的?唯一的约束是,变量a的write不能先于read,同理变量b也是这样。假设两个线程要进行可选的store和write操作,则主内存可能以三种顺序合法地执行其操作:

  • 1、hither完全领先于yon执行:取出b(2)赋值给a,a=2;然后线程2开始执行,取a为2赋给b。最后 a=b=2

  • 2、yon完全领先hither:取出a=1赋给b,b=1,然后hither执行,a=b=1,最后a=b=1

  • 3、交叉执行,yong先读走a=1,hither先读走b=2,然后各自继续执行,最后a,b值交换得 a=2,b=1

因此,最终结果可能是,在主存中,b被复制到a中,a被复制到b中,或者a和b的值被交换;此外,变量的工作副本可能一致,也可能不一致。这几种可能出现的概率完全由程序执行的时机顺序。

现在给方法加上syncronized关键字

class SynchSample {
    int a = 1, b = 2;
    synchronized void hither() {
    	a = b;
    }
    synchronized void yon() 
    	b = a;
    }
}

根据前面的规则,synchronized在方法执行前会将进行lock操作,对加锁操作,执行完后释放锁。保证同时只有一个线程执行。

执行流程大概如下图:

这保证了第一次三种执行情况交叉执行的情况不会发生,但是1和2两种情况还是都有概率会出现。

8.11 例:无须写

示例代码如下:

class Simple {
    int a = 1, b = 2;
    void to() {
    	a = 3;
    	b = 4;
    }
    void fro() 
    	System.out.println("a= " + a + ", b=" + b);
    }
}

还是加锁两个线程执行这两个方法,执行顺序会是怎样的?

先看执行to方法的线程,会对a和b执行赋值操作,但是有可能还未执行store操作的时候第二个线程开始执行fro方法了。所以fro躲到的a有可能是1或3,b有可能是2和4.会有4种组合情况。

下一步先对to方法加上syncoronized

class SynchSimple {
    int a = 1, b = 2;
    synchronized void to() {
    	a = 3;
    	b = 4;
    }
    void fro() 
    	System.out.println("a= " + a + ", b=" + b);
    }
}

结果和第一次不加synchronized是一样的。因为fro是非synchronized方法,read操作是不受lock操作影响的。

最后fro方法也加上synchronized关键字。保证两个方法不交叉执行,最后输出结果"a=1, b=2" or "a=3, b=4".

8.12 线程

线程由类Thread和ThreadGroup创建和管理。创建Thread对象会创建一个线程,这是创建线程的唯一方法。创建线程时,该线程尚未处于活动状态;当调用其start方法时,它开始运行。

8.13 锁和同步

每个对象都有一个锁。Java编程语言没有提供执行单独的锁定和解锁操作的方法。它们是由高级结构隐式执行的,这些结构总是正确地安排对这些操作进行成对操作。

syncoronized语句计算对对象的引用;然后,它尝试对该对象执行锁操作,并且在锁操作成功完成之前不会继续进行下一步操作。锁操作执行成功后,同步块才会执行。Java编程语言的编译器确保无论何时同步语句完成(无论正常完成还是异常中断完成),由在同步语句主体执行之前执行的monitorenter指令实现的锁操作都与由monitorexit指令实现的解锁操作相匹配。

synchronized方法在被调用时,自动执行lock操作。方法体只有锁执行成功后才会开始执行。弱国方法是实例方法,会锁住当前执行该方法的实例对象。如果是static方法,会锁住定义这个方法的class对应的Class对象。方法体执行完城后,会释放对应的锁。

最佳实践是,如果一个线程分配变量,另一个线程使用或分配变量,那么对该变量的所有访问都应该包含在同步方法或同步语句中。

虽然Java编程语言的编译器通常保证锁的结构化使用(参见第7.14节,“同步”),但不能保证提交给Java虚拟机的所有代码都遵守这个属性。Java虚拟机的实现可以强制执行以下两个保证结构化锁定的规则,但不是必需的。

设T是一个线程,L是一个锁:

  • 1、无论方法调用是正常完成还是异常完成,T在方法调用期间对L执行的锁定操作的数量必须等于T在方法呼叫期间对L进行的解锁操作的数量
  • 2、在方法调用期间,自方法调用以来T对L执行的解锁操作的数量不得超过自方法调用之后T对L进行的锁定操作的数量

用不太正式的术语来说,在方法调用期间,L上的每个解锁操作都必须与之前L上的一些锁定操作相匹配。

请注意,Java虚拟机在调用同步方法时自动执行的锁定和解锁被认为发生在调用方法的调用过程中。

8.14 等待列表和通知

每个对象除了具有关联的锁之外,还具有关联的等待列表,这是一组线程。首次创建对象时,其等待集为空。等待集由类Object的方法Wait、notify和notifyAll使用。这些方法还与线程的调度机制交互。

只有当当前线程(调用它T)已经锁定对象的锁时,才应该为对象调用方法wait。假设线程T实际上已经对该对象执行了N次锁定操作,而这些操作与对同一对象的解锁操作不匹配。然后,wait方法将当前线程添加到对象的等待集,出于线程调度的目的禁用当前线程,并对对象执行N次解锁操作以放弃对它的锁定。线程T在T要等待的对象以外的对象上锁定的锁不会被放弃,然后线程T处于休眠状态,直到发生三件事中的一件:

  • 其他线程调用该对象的notify方法,而线程T恰好是被任意选为要通知的线程
  • 其他线程调用该对象的notifyAll方法
  • 如果线程T对wait方法的调用指定了一个超时间隔,当达到指定的超时时间

然后线程T从等待集中移除,并重新启用线程调度。然后它再次锁定对象(这可能涉及以通常的方式与其他线程竞争);一旦它获得了对锁的控制,它就对同一对象执行N - 1个额外的锁操作,然后从wait方法的调用中返回。因此,在从wait方法返回时,对象的锁的状态与调用wait方法时的状态完全相同。

只有当当前线程已经锁定对象的锁时,才应该为对象调用notify方法,否则将抛出IllegalMonitorStateException。如果对象的等待集不为空,则从等待集中删除一些任意选择的线程,并重新启用线程调度。(当然,在当前线程放弃对象的锁之前,该线程将无法继续。)

只有当当前线程已经锁定对象的锁时,才应该为对象调用notifyAll方法,否则将抛出IllegalMonitorStateException。对象的等待集中的每个线程都从等待集中删除,并重新启用线程调度。(在当前线程放弃对象的锁之前,这些线程将无法继续。)

posted @ 2023-05-24 09:05  朋羽  阅读(19)  评论(0编辑  收藏  举报