关于Java多线程的思考

作者:愤怒的汉堡

https://mdnice.com/writing/dad7526ee42a457093c1273eba85e134

theme: condensed-night-purple highlight: agate

在一个系统中,每个对象都会保证自己的完整性。想做到这一点有时需要其他方法进行配合。独占技术就出现了

独占技术是用来保证对象稳定不变,并且避免即使是瞬间的状态冲突所带来的的影响。所有的方法都基于以下三个基本策略:

  1. 通过确保所有的方法从不同时修改一个对象表现形式,也就是对象永远不会进入不一致的状态来消除部分或者所有的独占控制需要
  2. 通过加锁或者其他的动态机制来保证,一个对象在同一时刻只能被一个线程访问
  3. 通过隐藏或者限制对象的使用权,来结构性地保证只能有一个线程可以使用该对象

不变性

具有不变性最简单的对象,是对象中根本没有数据。因此,它们的方法都是没有状态的,可以理解为这些方法不依赖于任何对象的任何数据。例如:

public class StatelessAdder {    public static int adder(int a,int b){        return a + b;    }

同样的安全性在具有final关键字修饰的数据的类中也适用。这样的类实例不会面临底层的读-写冲突和写-写冲突。因为其值不会被改写。并且,只要它们的初始值是以一种一致的、合理的方式创建,那么这些对象在更高的层面上也不会出现不变性方面的错误,例如:

class ImmutableAdder{ private final int offset; public ImmutableAdder(int a){offset = a;} public int addOffset(int b){return offset + b;}}

结构

在构造函数执行结束之前,不能访问对象的数据 和串行编程相比,在并发编程中这一点更难以保证。构造函数应该只执行与初始化数据相关的操作如果一个方法依赖于对象初始化完成,那么构造函数就不应该调用该方法。 如果一个对象是在其他类可存取的成员变量或者表中创建的,那么构造函数应该避免使用该对象的引用,避免用this关键字来调用其他的方法。可以理解为,要避免this产生的泄漏错误

同步

使用锁可以避免在底层的存储冲突和相应的高层面上的不变约束冲突,例如:

class Event{ private int n = 0; public int add(){  ++n;  ++n;  return n; }}

上述程序中没有加锁,如果多个线程同时执行Event中的add()方法时,可能会造成数据不一致错误。

加锁示例:

class Event{ private int n = 0; public synchronized int add(){  ++n;  ++n;  return n; }}

上述程序,在add()方法添加了synchronized关键字,这样就可以避免冲突的执行路线

机制

每一个Object类以及子类的实例都拥有一把锁。而int以及float等基本类型都不是Object类。基本类型只能通过包含他们的对象被锁住。每一个单独的成员变量都不能标记为synchronized。锁只能在成员变量的方法中应用。成员变量可以被声明为volatile类型,这将影响成员变量的原子性,可见性和顺序性。 包含基本类型元素的数组也是拥有锁的对象,但是数组中的基本元素却没有。锁住Object类型的数组却不能锁住他们其中的基本元素。 Class的实例是Object,Class对象的相关锁可以用在以static synchronized声明的方法中。

synchronized的用法:

synchronized void func(){...}

void func(){synchronized(this){...}}

synchronized关键字不属于方法签名的一部分,所以子类继承父类的时候,synchronized修饰符不会被继承。因此,接口中的方法不能被声明为synchronized。同样地,构造函数不能被声明为synchronized,构造函数中的程序可以被声明为synchronized。 子类和父类的方法使用同一个锁,但是内部类的锁和它的外部类无关,然而,一个非静态的内部类可以锁住它的外部类,例如 : synchronized (OuterClass.this){}

锁的申请和释放

锁的申请和释放都是在使用synchronized关键字时根据内部的底层的申请释放协议来使用的。所有的锁都是块结构,当进入synchronized方法时得到锁,退出时释放锁。 锁操作基于”每个线程”而不是“每次调用”。 synchronized和原子操作(atomic)不是等价的。但是同步可以实现原子操作。 如果一个线程释放了锁,那么其他线程可以得到它,但是无法保证线程在什么时候得到锁,这里没有公平可言。 JVM在类加载和初始化的时候为Class类自动申请和释放锁。

完全同步对象

锁是最基本的信息接收控制机制。如果,S客户想要调用一个对象的方法,而另一个方法或者代码块正在执行,那么锁可以阻塞用户。

原子对象

基于锁的最安全并发面向对象设计策略是,把注意力限制在完全同步中:

  • 所有方法都是同步的
  • 没有公共的成员变量,或者其他封装问题
  • 所有方法都是有限的,(不存在无休止的递归和无限循环),所有操作最终会释放锁
  • 所有成员变量在构造函数中已经初始化为稳定一致的状态
  • 在一个方法开始和结束的时,对象状态都应该稳定一致,即使出错也应该如此

死锁

例如

public static void main(String[] args) {        Resource1 resource1 = new Resource1("resource1");        Resource1 resource2 = new Resource1("resource2");        Thread t1 = new Thread(() ->{            for (int i = 0; i < 100; i++) {                resource1.saveResource(resource2);            }        });        Thread t2 = new Thread(() ->{            for (int i = 0; i < 100; i++) {                resource2.saveResource(resource1);            }        });        t1.start();        t2.start();    }

上述代码在执行过程中就会产生死锁。

死锁:就是在两个线程或者多个线程都有权访问两个对象或者多个对象,并且线程都在已经得到一个锁的情况下等待其他线程释放锁。

死锁解决办法

顺序化资源

为了避免死锁或者其他活跃性失败,我们需要其他独占技术,例如顺序化资源。

顺序化资源:

是把每一个嵌套的synchronized方法或者代码块中使用的对象和数字标签关联起来。如果同步操作是根据对象标签的大小顺序排列,那么死锁就不会发生。 线程A获取了1的同步锁正在等待2的同步锁,按照初始化顺序就可以避免死锁发生。例如:

public class ApplyLock {    private List<Object> listOf = new ArrayList<>();    public synchronized boolean applyLock(Resource resource1,Resource resource2){        if (listOf.contains(resource1) || listOf.contains(resource2)){            return false;        } else {//按照顺序初始化资源            listOf.add(resource1);            listOf.add(resource2);            return true;        }    }    public synchronized void freeListOf(Resource resource1,Resource resource2){        listOf.remove(resource1);        listOf.remove(resource2);    }}

我们还可以使用System.identityHashCode()的返回值。即使类本身已经覆盖了hashCode方法,但是System.identityHashCode()还是会直接调用hashCode。我们虽然无法百分之百保证System.identityHashCode()的返回值的唯一性,但在实际运行的系统中,这个方法的唯一性在很大程度上得到了保证。 例如:

public synchronized boolean applyLock(Resource resource1,Resource resource2){        if (listOf.contains(resource1) || listOf.contains(resource2)){            return false;        }else if (System.identityHashCode(resource1) < System.identityHashCode(resource2)) { //保持顺序性            return true;        }//        else {//保持顺序性//            listOf.add(resource1);//            listOf.add(resource2);//            return true;//        }        return false;    }    public synchronized void freeListOf(Resource resource1,Resource resource2){        listOf.remove(resource1);        listOf.remove(resource2);    }

Java存储模型

按照顺序执行

	private int a=0;
	private long b=0;
	void set(){
		a = 1;
		b = -1;
	}
	boolean check(){
		return ((b == 0)||(b ==-1 && a == 1))
	}
}

在纯串行化的语言里,check方法永远不会返回false。 在并发环境下,就会有完全不同的结果。一个线程在调用set,另一个线程在调用check,那么很有可能导致最后的结果为false。check的执行可能会被优化执行的set打断。这时就会发生check返回false的情况。这显然不是我们想看到的结果。 在并发编程中,不仅仅可以有多条语句交叉执行,而且可以打乱顺序执行,或者被优化后执行。 我们在设计编写多线程程序时,必须使用同步来避免由于优化而引发的复杂性

模型只定义线程和主内存的抽象关系,每一个线程都有一个工作存储空间(缓存或寄存器的抽象)用来存储数据。模型保证了与方法相关的指令顺序以及与数据相关的存储单元这两者之间的一些交互的特性。很多规则都是根据何时主存和每线程工作存储空间之间传送数据来描述的,主要围绕一下三个问题

  1. 原子性(Atomicity)。指令必须有不可分割性。为了建模的目的,规则只需要阐述对代表成员变量的存储单元的简单读写操作。这里的成员变量可以使实例对象和静态变量,包括数据,但是不包含方法中的局部变量
  2. 可见性(Visbility)。在什么情况下一个线程的效果对另一个线程是可见的。这里的效果是指写入成员变量的值对于这个成员变量的读操作是可见的。
  3. 顺序化(Ordering)。在什么情况下对一个线程的操作是可以允许无序的。主要的顺序化问题围绕着读写有关的赋值语句的顺序。

原子性

原子性保证我们获取到的值是初始值或者被某些线程修改后的值,绝不是经过多个线程同时修改而得到的混乱的数据。但是我们要知道,原子性本身并不能保证程序获得的值是线程最近修改的值。由于这个原因,原子性本质上对并发的程序的设计没有多大影响。分布式系统中我们一般要求最终一致性。

可见性

只有在下列的情况中,线程对数据的修改对于另一个线程而言才是可见的:

  1. 写线程释放了锁,读线程获取到了该同步锁 
    1. 在释放锁的时候要把线程所使用的工作存储单元的值刷新到主内存中,获得锁的时候要中心加载可访问的成员变量的值。锁只为同步方法或者中的其他操作提供独占意义。
    2. 同步具有双重意义:它既通过锁支持高层同步协议,同时也支持存储系统,从而保证多个线程中的数据同步。相对于串行编程来说,分布式编程和并发编程更具有相似性。synchronized的第二个意义在于,使得一个线程中的方法可以发送或者接收另一个线程方法中对于共享数据的修改信息。从这个角度来看,使用锁和发消息只是说法不同
  2. 如果一个成员变量被声明为volatile,那么在写线程操作存储之前,写入这个volatile成员变量的数据在主存中刷新,并使其对其他线程可见。读线程在每次使用volatile变量之前都要重新读入数据。
  3. 当一个线程访问一个对象的成员比那里,那么线程获取的值不是初始值就是被其他线程修改过的值
  4. 当一个线程操作结束,所有的写入数据都要被刷新到主内存中 
    1. 例如,线程A使用Thread.join方法和线程B结束同步,那么线程A肯定可以看到线程B的执行结果
    2. 在同一个线程内的不同方法之间传递对象的引用,永远不会引起可见性问题

volatile

在原子性,可见性以及排序方面,把一个数据声明为volatile几乎等价于使用一个小的synchronized修饰的get,set方法:

class VFloat{ private float v; synchronized void set(float f){v = f} synchronized void get(){return v}}

但是,需要注意的

是,对于复合的读写操作,volatile并不能保证其原子性,例如:i++操作

发布者:Johngo学长。文章已受到原创版权保护。
转载请注明出处:https://www.johngo689.com/4624/

(1)
上一篇 2022年2月14日 下午1:27
下一篇 2022年3月16日 下午2:38

相关推荐

发表评论

登录后才能评论
免费咨询
免费咨询
扫码关注
扫码关注
联系站长

站长Johngo!

大数据和算法重度研究者!

持续产出大数据、算法、LeetCode干货,以及业界好资源!

2022012703491714

微信来撩,免费咨询:xiaozhu_tec

分享本页
返回顶部