二、Java并发编程实战之对象的共享
发布日期:2021-09-12 09:57:53 浏览次数:19 分类:技术文章

本文共 9527 字,大约阅读时间需要 31 分钟。

文章目录

对象的共享

我们已经知道同步代码块和同步方法可以确保以原子的方式执行操作,但是一种常见的误解是:认为synchronized只能用于实现原子性和确定"临界区",同步还有一个更重要的方面"内存可见性"。
我们不仅希望防止某个线程正在使用对象状态而另外一个线程在同时修改这个状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化,如果没有同步,那么这种情况就无法实现。
可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。

一、可见性(volatile可以保证可见性)

**在多线程中,我们无法确保执行读操作的线程能够实时地看到其他线程写入的值。**有时候甚至是不可能的事情,为了保证多个线程之间对内存写入操作的可见性质,必须使用同步机制。

可见性:一个线程对主内存的修改可以及时对被其他线程观察到

导致共享变量在线程间不可见的原因?
1、线程交叉执行
2、"重排序"结合线程交叉执行
3、共享变量更新后的值没有在工作内存与主内存之间及时更新。

import java.util.concurrent.TimeUnit;class MyData{

   int number = 0;
public void addTo60()
{
   this.number = 60;
}}
/**
 * 1、验证volatile的可见性。
 *  1.1 假如int number = 0; number变量之前没有添加volatile关键字修饰
 *  1.2添加volatile可以解决可见性问题
 *
  */
public class VolatileDemo
{
   public static void main(String[] args)
{
   MyData myData = new MyData();
//下面一直到while之前都是lambda表达式
new Thread(()->{
   System.out.println(Thread.currentThread().getName()+"\t come in");
try
{
   TimeUnit.SECONDS.sleep(3);
}
catch (InterruptedException e)
{
   e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName()+"\t updated number value:"+myData.number);
},"AAA").start();
//第二个线程就是我们的main线程
while(myData.number==0)
{
   //main线程就一直在这等待,直到number值不再等于0
}
System.out.println(Thread.currentThread().getName()+"\t mission is over");
}}

如果变量没有加volatile,那么main就会一直循环,不会打印最后是sout。加了volatile就会输出。

通过前面的对JMM的介绍,我们知道:
各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的。
这就可能存在一个线程AAA修改了共享变量X的值但并未写回主内存时,另外一个线程BBB又对主内存中同一个变量X进行操作,但是此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

volatile可以保证原子性,及时通知其他线程,主物理内存的值已经被修改了。

1、失效数据

在上面代码中,主线程在没有加volatile的时候可能拿到的就是一个失效值。。除非在每次访问变量的时候使用同步,否则很可能会得到失效的值。

public class MyTest{
   
public int value;
public int getValue()
{    
return value;
}
public void setValue(int value)
{    
this.value = value;
}}

在set和get方法没有同步的情况下访问value,但是可能某个线程调用了set方法,另外一个线程就可能看到了更新的值。

2、非原子的64位操作

对于64位数值的long、double,如果不加锁或者不加volatile修饰,那么它的读写就会分解为两个32位的操作

当线程在没有同步的情况下读取数据的时候,可能会得到一个失效值,但是至少这个值至少是某个线程设置的值,而不是一个随机值,这种安全性保证也被称为"最低安全性"。

最低安全性适用于绝大多数变量,除了:非volatile的64位数值变量(long和double)。Java内存模型中读取和写入都必须是原子操作,“但是对于非volatile的64位数值变量,JVM允许将64位的读操作或者写操作分解为两个32位的操作,当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的程序中执行,那么可能读取到某个值的高32位和另一个值的低32位”。所以在多线程中使用共享可变的long和double类型的变量也是不安全的,除非用关键字volatile来声明他们,或者用锁保护起来。

3、加锁与可见性

JMM关于synchronized的两条规定:
1、线程解锁前,必须把共享变量的最新值刷新到主内存中。
2、线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁必须是同一把锁)**

当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果,如果没有同步,那么就无法实现上述保证,注意要是同一个锁。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性,为了确保所以线程都能看到共享变量的最新值,所以执行读操作或者写操作的线程都必须在同一个锁上同步。

4、Volatile变量(只能保证可见性,不能保证原子性)

volatile 英[ˈvɒlətaɪl]

volatile用来确保将变量的更新操作通知到其他线程,当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作和其他内存操作一起重排序。

volatile变量只能保证可见性,并不能保证原子性

读之前,写之后添加指令
通过加入内存屏障和禁止重排序优化来实现:
1、对volatile变量写操作的时候,会在写操作后加入一条store指令,将本地内存中的共享变量的值刷新到主内存。
2、对volatile变量读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

Java内存模型规定了8个操作来完成主内存和工作内存之间的交互操作(这几个操作具有原子性,除了对于64位数据即double、long的读写):

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态
  • unclock(解锁):作用于主内存的变量,把一个处于锁定的状态释放出来
  • read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中
  • load(载入):作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值 赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传递到主内存,以便write操作使用。
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
    在这里插入图片描述

volatile不足以确保递增操作(如:count++)的原子性(原子性提供了读-改-写)。因为每次虽然读到的是最新的,但是可以两个线程同时读到最新的,就丢掉了一次++。

加锁机制既可以确保可见性,又可以确保原子性,而volatile只能确保可见性。

import java.util.concurrent.TimeUnit;class MyData{

   volatile int number = 0; //和上面代码的区别就在于在这加上了volatile
public void addTo60()
{
   this.number = 60;
}}
/**
 * 1、验证volatile的可见性。
 *  1.1 假如int number = 0; number变量之前没有添加volatile关键字修饰
 *  1.2添加volatile可以解决可见性问题
 *
  */
public class VolatileDemo
{
   public static void main(String[] args)
{
   MyData myData = new MyData();
//下面一直到while之前都是lambda表达式
new Thread(()->{
   System.out.println(Thread.currentThread().getName()+"\t come in");
try
{
   TimeUnit.SECONDS.sleep(3);
}
catch (InterruptedException e)
{
   e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName()+"\t updated number value:"+myData.number);
},"AAA").start();
//第二个线程就是我们的main线程
while(myData.number==0)
{
   //main线程就一直在这等待,直到number值不再等于0
}
System.out.println(Thread.currentThread().getName()+"\t mission is over");
}}

在上面变量number加了volatile修饰,所以上面的main线程会输出mission is over结束。

二、发布与逸出

1、发布

“发布”一个对象指的是使对象能够在当前作用域之外的代码中使用。例如将一个指向该对象的引用保存到其他代码可以访问到的地方,**如将变量保存为公有?**或者在一个非私有的方法中返回该引用即return。

public static Set
   
     knownSecrets; //注意这是public的public void initialize(){
   
    
knownSecrets = new HashSet ();}

如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

比如一个发布Set类型的变量,Set内的这些引用也都会被发布。可能会因此造成逸出

2、逸出

当某个不应该被发布的对象被发布的时候,这种情况就称为逸出。比如在对象构造完成之前就发布该对象,就会破坏线程安全性。

不要在构造函数中使this逸出。
Escape类自己本身构造函数还没有完成,this指针就在别的内部类中使用,这显然是不应该的。正确的做法是构造函数返回时,this引用才能从线程中逸出,才能在该线程中被其他类的方法使用。

import com.sun.org.apache.bcel.internal.classfile.InnerClass;
public class Escape
{
   private int thisCanBeEscape = 0;
public Escape()
{
   new InnerClass();
}
private class InnerClass
{
   public InnerClass()
{
   Escape.this.thisCanBeEscape = 2;
 }
}
}

三、线程封闭(保证线程安全的最简单方式之一)

当访问共享概的可变数据的时候,需要同步,一种避免同步的方式就是不共享数据。
线程封闭是线程安全性的最简单实现方式之一。当一个对象封闭在一个线程中的时候,这种用法将自动实现线程安全性。即使封闭的对象本身不是线程安全的。
如JDBC的Connection对象,因为线程从连接池中获得一个Connection对象,并且在对象返回之前,连接池不会再将它分配给其他线程,这种连接模式隐含地将Connection对象封闭在线程中。

1、Ad-hoc线程封闭

Ad-hoc线程封闭指的是维护线程封闭性的职责完全由程序实现来承担。(用的很少,不需要再看书了。)

2、栈封闭

栈封闭是线程封闭的一个特例,指的是只能通过局部变量才能访问对象。
局部变量的一个固有属性就是封闭在执行线程之中,它位于执行线程的栈中,其他线程无法访问这个栈。也就实现了线程封闭。

3、ThreadLocal类

维持线程封闭性质的一种更规范的方法是使用ThreadLocal。这个类能使得线程中的某个值与保存值的对象关联起来。
ThreadLocal提供了get和set等访问接口或者方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回当前线程在调用set时设置的最新值。

ThreadLocal通常用于防止对可变的单实例变量或者全局变量进行共享。
Connection对象是线程不安全的,通过将JDBC连接对象放在ThreadLocal对象中,每个线程都会用于术语自己的连接。

public static ThreadLocal
   
     connectionHolder = new ThreadLocal
    
     (){
   
     
public Connection initialValue()
{    
return DirverManager.getConnection(DB_YRL);
}};public static Connection getConnection(){    
return connectionHolder.get();}

四、不变性

不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化。

/*由于ImmutableObject不提供任何setter方法,并且成员变量value是基本数据类型,getter方法返回的是value的拷贝,所以一旦ImmutableObject实例被创建后,该实例的状态无法再进行更改,因此该类具备不可变性。*/public class ImmutableObject {

   private int value;
 public ImmutableObject(int value)
 {
   this.value = value;
}
 public int getValue()
 {
   return this.value;
}}

如果某个对象被创建后其状态就不能被修改,那么这个对象就称为不可变对象,线程安全性质是不可变对象的固有属性。
不可变对象一定是线程安全的。

虽然Java语言规范和Java内存模型中都没有给出不可变性的正式定义,但是不可变性并不等于将对象中的所有域声明为final。即使所有的域都是final的,这个对象也仍然是可变的,因为final的引用中也可以保存可变对象的引用。
也就是比如一个引用是final的,其实只是这个地址不能变化,但是其内容还是可以进行变化的。

public class NoChange{

   public static void main(String[] args) {
   final int[] nums = {    1,3,4,4};
nums[0] = 2;
for(int i=0;i
{
   System.out.println(nums[i]);
}
}}

对象不可变的条件

  • 对象创建之后,其状态就不可以被修改。
  • 对象的所有域都是final类型。
  • 对象是正确创建的(在对象的创建期间,this没有逸出)

1、final域

不可变对象如String。和不可变引用不是一个东西。
不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化。

final能够确保初始化的过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。
通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的。

2、使用volatile类型来发布不可变对象

@Immutableclass oneValueCache{
   
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public oneValueCache(BigInteger i,BigInteger[] factors)
{
   
lastNumber = i;
lastFactors = Arrays.copyOf(factors,factors.length);
}
public BigInteger[] getFactors(BigInteger i)
{    
if(lastNumber == null || !lastNumber.equals(i))
return null;
else 
return Arrays.copyOf(lastFactors,lastFactors.length);
}}@ThreadSafepublic class VolatileCachedFactorizer implements Servlet{
   //在这里类型使用了volatile,当一个线程将volatile类型的cache设置为一个新的时,其他线程就会立刻看到新缓存的数据。
private volatile oneValueCache cache = new OneValueCache(null,null); 
public void service(ServletRequest req,ServletResponse resp)
{    
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if(factors == null)
{    
factors = factor(i);
cache = new OneValueCache(i,factors); "返回null会重新创建类对象"
}
encodeIntoResponse(resp,factors);
}}

五、安全发布

1、不正确的发布:正确的对象被破坏

由于存在可见性问题,其他线程看到的holder对象将处于不一致的状态。书上该例子说holder未正确发布,确实holder对象不是一个不可变对象,在多个线程共享该对象时没有采用同步、锁的方式保证对holder对象的操作的原子性、可见性。

class Holder{
   
private int n;
public Holder(int n) 
{    
this.n = n;
}
public void assertSanity()
{
   if(n != n) //在多线程的时候,这两个n可能得到的是不同的值。
{
   throw new AssertionError("this statement is false.....");
}
}
public int getN()
{
   return n;
}
public void setN(int n)
{
   this.n = n;
}}

2、不可变对象和初始化安全性

不可变的需求上面已经讲了。
包括状态不可修改、所有域都是final类型的。以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全的访问不可变对象,即使在发布这些对象的时候没有使用同步。
如果final类型的域所指向的是可变对象,那么在访问这些域所指向的状态的时候仍然需要同步。

3、安全发布的常用模式

可变对象必须通过安全的方式来发布,这通常着发布和使用该对象的线程都必须使用同步。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下的方式安全地发布:

  • 在静态初始化函数中初始化一个对象引用
  • 将对象的引用保存到volatile类型的域或者AtomaticReferance对象中
  • 将对象的引用保存至某个正确构造对象的final类型域中
  • 将对象的引用保存到一个由锁保护的域中

java提供了一些线程安全库中的容器类,提供了安全发布的保证。例如:Hashtable、ConcurrentMap、synchronizedMap、Vector、BlockingQueue等。
 最简单的线程安全的对象发布,采用的是通过静态方法创建,类似于工程方法:
  public static Holder hold=new Holder(42);

4、对于安全发布对象的总结:

  • 线程封闭,对象范围是只在1个thread范围中,采用线程封闭技术,那么不需要同步机制,因为该对象是thread独有的
  • 只读共享,如果对象是只读的,那么也不需要同步机制,没有任何修改操作。
  • 线程安全类,如果对象是在Thread-safe结构中进行共享,如Hashtable等,那么该结构已经提供了同步机制,可以放心使用
  • 其它。则该对象如果存在读写操作,需要相应的进行锁机制、同步机制,公用同样的锁来保证数据的完整一致

高端用法:多个线程之间将一个Date对象作为不可变对象来使用,那么在多个线程共享Date对象时,可以省去锁的使用,假设需要维护一个Map对象,保存每个用户的最近登录时间:
public Map<String,Date> lastlogin = Collections.synchronizedMap(new HashMap<String,Date>());

转载地址:https://blog.csdn.net/weixin_38367817/article/details/103737545 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:三、Java并发编程实战之对象的组合
下一篇:一、Java并发编程实战之线程安全性

发表评论

最新留言

能坚持,总会有不一样的收获!
[***.139.53.9]2022年09月29日 15时06分08秒

关于作者

    喝酒易醉,品茶养心,人生如梦,品茶悟道,何以解忧?唯有杜康!
-- 愿君每日到此一游!

最新文章