关于线程 thread (3)线程的同步

同步问题的提出。

线程同步主要是为了防止多个线程访问同一个数据对象的时候,对数据造成破坏。因为从一开始我们就讲了,线程自身有的资源很少,大部分是和其他线程共享进程中的资源。

例如两个线程 A 和 B,都操作同一个对象,并且修改这个对象里面的数据。假设有两个线程,想改同一个字符串的内容。看看会有什么后果。

package Thread;

public class ThreadDemo2 implements Runnable {
	Foo foo = new Foo();
	
	public static void main(String[] args) {
		ThreadDemo2 demo2 = new ThreadDemo2();
		Thread t1 = new Thread(demo2);
		Thread t2 = new Thread(demo2);
		t1.start();
		t2.start();
	}
	
	public void run() {
		for (int i = 0; i < 30; i++) {
			foo.fix(30);
			try {
				System.out.println(Thread.currentThread() + " : x = " + foo.getX());
				Thread.sleep(1);
			} catch (Exception e) {
				// TODO: handle exception
			}
			
			
		}
	}
}

class Foo{
	int x = 100;
	public int getX(){
		return x;
	}
	
	public int fix(int y){
		x -= y;
		return x;
	}
}

关于线程 thread (3)线程的同步_第1张图片
看见了吧,,,事实上总是超乎你的想象,,40, -20, -80,怪有规律,还TM双双出现。 想象中的应该是我不管两个线程到底先走谁,,怎么着也得是 30递减啊?!我的目的就是30递减啊!!怎么会是这样的结果?脑子里模拟代码怎么走就明白了,首先要清楚一点,线程这玩意儿,不做专门的控制的话,它可能在任何代码行的执行后,切换!因为这样超乎我们正常思维的执行,所以就有了各种超乎我们判断的运行结果。上面的结果很明显是线程0首先走了一下,但是还没有来得及走到打印log的那行代码,sleep了一下,切了线程,然后线程1就走到给减掉了30, 但是还是没有走到打印线程1log 的时候,又切换线程了,于是线程0就继续走,走到打印log的那行的时候,数据不是减掉30,事实上已经被减掉60了!!所以打印出了这么一波。。

刚刚我们说到了里面有sleep方法的调用。导致切切切。。。那么把这个函数去掉呢??会以30递减吗???我走了一下,结果也挺惨的。。。
关于线程 thread (3)线程的同步_第2张图片
反正就是乱了乱了,,针对这种多线程对共享数据操作的,,不好好控制真的容易出五花八门的bug。。

那如果想要保持结果的合理性,我们要怎么做呢?只需要达到一个目的,就是将foo的访问加以限制,每次只有一个线程能访问。这样就能保证foo对象中数据的合理性了。synchronized,熟悉的关键词就是用来解决这种问题的。

在具体的java代码中需要完成以下两个操作:

  1. 把竞争访问的资源 foo.x标示为private
  2. 同步那些修改变量的代码,使用 synchronize关键字同步方法或代码。

解释:同步和异步。 同步指的是两件事请有先后顺序,必须前者返回的时候我才能进行下去。异步与之相反,就是管你返回来没返回来呢,我继续我的其他事情,你要是返回来了,就给我说一声,我这时候再根据返回的结果做相应的逻辑处理。 这个在安卓编程里面经常用到的,例如 根据从服务端拉下来的数据做手机界面上的更改。 主线程会直接发一个请求消息,然后这个请求消息直至接收数据这堆庞大还可能耗时的逻辑,就直接交给子线程处理了,主线程做的不过就是调了一个发消息的方法而已,,然后管你子线程来没来返回结果的,我继续走我生命周期中该做的其他事情。然后子线程来了消息,就会告知结果返回来了(设置监听器),主线程得知,立马对其进行处理。这样谁都不耽误。。

锁!用来解决上节问题的噩梦级工具!

关于java中的锁的理解(通俗易懂)

java中,每个对象都有一个内置锁!一段synchronized的代码被一个线程执行之前,他首先要拿到执行这段代码的权限,在java里面就是拿到了某个同步对象的锁,如果这个时候同步对象锁被其他线程拿走了,它就只能等待了(线程被阻塞到锁池等待队列中)。取到锁后,他就开始执行同步代码,然后这段代码执行完之后就马上把锁还给被同步的对象,在其他锁池中等待的线程就可以拿到锁执行他们的同步代码了,这样就保证了代码在同一时刻只有一个线程执行。

众所周知:在java多线程编程中,有一个非常重要的问题就是线程的同步问题。关于线程的同步通常有以下几种方法:

  1. 在需要同步的方法的方法签名中加入synchronized关键字,
  2. 使用synchronized块对需要同步的代码进行同步。
  3. 使用JDK 5 中提供的java.util.concurrent,lock 包中的Lock对象

另外为了解决多个线程对同一白能量进行访问时可能发生的安全性问题,我们不仅可以采用同步机制,更可以通过JDK 1.2中加入的ThreradLocal来保持更好的并发性。

线程的先来后到

线程的同步机制是靠锁来的概念来控制的,那么在java中,锁是如何体现出来的呢?

这里有必要说一下JVM的内存分配,因为接下来会涉及到这波常识,用来判断什么是属于线程自己资源其他线程不能访问,怎样才确定不同线程请求的是同一把锁:

参考来源:
Java内存图以及堆、栈、常量区、静态区、方法区的区别
请输入关键词 浅谈java+内存分配及变量存储位置的区别
从几个sample来学习Java堆,方法区,Java栈和本地方法栈

  • 栈:主要存放在运行期用到的一些局部变量(基本数据类型变量)或者指向其他对象的一些引用,因为方法执行时,被分配的内存就在栈中,所以当然存储的变量就在栈中喽。当一段代码或者一个方法调用完毕后,涉及到的基本数据类型或者对象的引用就会被立即释放。不知道大家有没有记起来前面的一些内容,,好像一个线程在start()的时候,会被分配它拥有的少量资源, 调用栈,寄存器,程序计数器。。如果调用栈和 栈 是同一个东西的话,,,额,,我好想发现了不得了的事情。。

  • 堆:主要存放java在运行过程中new出来的对象,凡是通过new生成的对象都放在堆中,对于堆中的对象生命周期的管理主要是java虚拟机的垃圾回收机制GC进行回收和统一管理。类的非静态成员变量也存放在堆中,其中基本数据类型是直接保存值,而复杂的类型保存指向对象的引用,非静态成员变量在类的实例化时开辟空间并且初始化。所以要知道类的几个时机, 加载-连接-初始化-实例化 详解请看 Java中类的加载概述和加载时机。

  • 静态域:位于方法区的一块内存,存放类中以static声明的静态变量

  • 常量池:位于方法区的一部分内存。存放常量,常量是变量的对立面,意指不可改变的量,说白了就是 final 修饰的那一波。final修饰的基本类型不可改值,final修饰的对象不可改指向它地址的引用,也就是有一个确定的唯一并且不可改的引用。常量池在编译期间就将一部分数据存放于该区域了,包含基本数据类型如 int , long, short, double等以final声明的常量值,和String字符串,特别注意的是对于方法运行期间位于栈中的局部变量String常量的值可以通过String.intern()方法将该值放到常量池中的! 记住String 存到常量池里!

  • 方法区:是各个线程的共享的内存区域,它用于存储class二进制文件,包含了虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。他有个名字叫Non-Heap(非堆),目的是与java堆区分开。

注意,静态域和常量池,被包含于 方法区里。 对于方法区,看下 方法区和常量池 这个文章,尤其是代码例子,挺有意思。

好吧讲完了内存方面,我们把思维从上面的内容里抽回来哈,看下正经的东西。。。让我们从JVM的角度来看看锁的概念,在java运行时的环境中,JVM需要对两类线程共享的数据进行协调。一种是保存在堆中的实例变量,二是保存在方法区中的类变量。这两种数据都是被所有线程共享的,程序不需要协调保存在java栈中的数据,因为这些数据是属于该栈的线程所私有的。

在java虚拟机中,每个对象和类在逻辑上都是和一个监视器相关联的,对于对象来说,相关联的监视器保护对象的实例变量,对于类来说,监视器是保护类的类变量。(如果一个对象没有实例变量,或者一个类没有变量,相关联的监视器就什么也不会监视。)为了实现监视器的排他性监视能力,java虚拟机会为每个对象都关联一个锁,代表任何时候只允许一个线程拥有的特权,线程访问实例变量或者类变量不需要锁。但是如果线程获取了锁,那么在他释放这个锁之前就没有其他线程可以获取相同数据的锁了。锁住一个对象就是获取对象相关联的监视器。

锁类实际上用对象锁来实现的,当虚拟机装载一个class文件的时候,他就会创建一个java.lang.Class类的实例。当锁住任何一个对象的时候,实际上就是锁住那个类的class对象。一个线程可以多次对同一个对象上锁。对于每一个对象,java虚拟机维护一个加锁计数器,线程每次获得一个锁,就加1,释放一个就减1,当计数器为0的时候,锁就被完全释放了。

那么这么复杂的内部原理,就为了达成一个抽象成的终极目标,假设我是被访问的数据,但是访问者过多并且过程过乱,导致老是出错!那么好吧,暴力处理,给我要数据可以,谁也别抄抄,一个一个来,要懂礼貌!这样对大家都好。那么我必须通过一种特定的方式来约束大家让大家按照一个一个来这种规则执行程序。

编程人员不需要自己动手加锁,对象锁是java虚拟机内部使用的。在java程序中,我们只需要用上synchronized或者synchronized方法就可以标志一个监视区域,当每次进入一个监视区域时,java虚拟机就会自动锁上类或对象。

当一个有限的资源被多个线程方向的时候,为了保证共享资源的互斥访问,我们一定要给他们哦爱出一个先来后到的规则,而做到这一点,对象锁起到了一个非常重要的作用。

我们对上面的代码demo做一下修改,那种不堪入目的结果就可以改好了。

package Thread;

public class ThreadDemo2 implements Runnable {
	Foo foo = new Foo();
	
	public static void main(String[] args) {
		ThreadDemo2 demo2 = new ThreadDemo2();
		Thread t1 = new Thread(demo2);
		Thread t2 = new Thread(demo2);
		t1.start();
		t2.start();
	}
	
	//用 synchronized 修饰方法,只是个例子,这里修饰的是run方法,实际开发中可别这样用哈
	public synchronized void run() {
			for (int i = 0; i < 30; i++) {
				foo.fix(30);
				try {
					System.out.println(Thread.currentThread() + " : x = " + foo.getX());
					Thread.sleep(1);
				} catch (Exception e) {
					// TODO: handle exception
				}
			}	
	}
}

class Foo{
	int x = 100;
	public int getX(){
		return x;
	}
	
	public int fix(int y){
		x -= y;
		return x;
	}
}

上面的代码只是对run方法进行了synchronized修饰了一下,就改了问题,运行结果如图:
关于线程 thread (3)线程的同步_第3张图片
结果终于呈30递减了,那么里面究竟发生了什么呢?首先synchronized如果修饰方法的话,就会把该方法所在的类的对象中的锁要过来,,然而main方法中,虽然是两个线程,但是在其初始化的时候,往里面塞的Runnable实现类对象是同一个对象!这就意味着,这两个线程执行run方法的时候,其所在的对象即使可能有不一样的引用,但是指向的地址是同一个对象,那么锁就不言而喻了,同一把锁!而且是Runnable实现类的锁!注意了啊,要是线程同步,你必须得保证锁是同一把!然而一把锁就对应了一个唯一的确定的对象,所以用synchronized的时候,你得确定不同的线程要获取的是同一把锁才有效!

还有第二种方法,看看:

	public void run() {
		synchronized (foo) {
			for (int i = 0; i < 30; i++) {
				foo.fix(30);
				try {
					System.out.println(Thread.currentThread() + " : x = " + foo.getX());
					Thread.sleep(1);
				} catch (Exception e) {
					// TODO: handle exception
				}
			}	
		}					
	}

看出把synchronized的修饰位置变了,改为了run方法里面。看下运行结果:
关于线程 thread (3)线程的同步_第4张图片
也是一种理想的结果哈!但变了一个位置,是否就表示和上面修饰方法一个含义呢?其实是不一样了,,两个线程需要的锁还是同一把,但不是Runnable实现类的,而是Foo的!

那么Synchronized的修饰范围如何控制呢?我把synchronized修饰的范围再缩小一波,搞到for循环里面看看有什么结果。我感觉也可以。。看看哈:

	public void run() {
			for (int i = 0; i < 30; i++) {
				//for循环里面加同步代码块
				synchronized (foo) {
					foo.fix(30);
					try {
						System.out.println(Thread.currentThread() + " : x = " + foo.getX());
						Thread.sleep(1);
					} catch (Exception e) {
						// TODO: handle exception
					}
				}					
			}
	}

运行结果:
关于线程 thread (3)线程的同步_第5张图片
正常的。这时候你可能会好奇了,,synchronized修饰的是for循环里面的数据,,,那for循环里面不是有个i = 0; i < 30; i ++ 么?这个不在synchronized修饰的范围内啊??多线程怎么没有把i给乱改啊?原因是i 它被存到了哪里!它是不是线程之间的共享数据!这个for循环里面的 i 显然就是运行时这个方法时临时产生的值,既不属于类的成员变量,也不是常量,静态变量,,这种显然就是栈里存的数据。那就好说了,栈里的数据对于线程是私有的,相当于各个线程人手一份互不影响。所以i这个数据绝对是线程安全。所以这样改的结果,打印出来也是对的。

关于锁和同步,有以下几个要点:

  • 每个对象只有一个锁,当提到同步时,应该清楚在什么上同步,也就是说要的是谁的锁。
  • 一个类里,可以有同步方法 和 非同步方法,非同步方法是可以被多个线程自由执行而不受锁的限制的。
  • 线程睡眠时,它所持有的锁不会被释放!
  • 线程可以获得多个锁。比如,在一个对象的同步方法里面调用了另外一个对象的同步方法,这样就获得了两把锁。
  • 同步损害并发性(想象一下大伙都被堵到了外头,都闲置啥活不干的场景,多浪费资源),应该尽量的缩小同步的范围,同步不但可以同步整个方法,还可以同步方法中的一部分代码,到底要拿谁的锁要搞清。

静态方法同步

要同步静态方法,需要一个用于整个类对象的锁,这个对象就是这个类 (xxx.class),上了锁,程序中所有这个类的对象,,都会同步。
例如:

public static synchronized void setName (String name) {
Xxx.name = name;
}

//等价于
public static void setName (String name) {
synchronized(Xxx.class) {
Xxx.name = name;
}
}

各种同步方式对应的锁

如果线程试图进入同步方法,但是其锁已经被占用,则线程在该对象上被阻塞。实质上,线程对象进入该对象的一种池中,锁池,必须在那里等着,直到锁被释放,该线程才会变成可运行状态。

当考虑阻塞时,一定要注意用的是哪个对象的锁:

  • 调用同一个对象中的非静态同步方法的线程将被阻塞,但是如果输不同对象的话,则他们执行同步方法的时候,持有的锁也是不同对象的,锁不一样!线程之间就不会互斥。
  • 调用同一个类的静态同步方法的不同线程将会阻塞,因为锁是同一个,都是class对象,class对象不一般,全局也就一个!
  • 静态同步方法和非静态同步方法调用,永远不会彼此阻塞,因为锁不同!一个是其对应的class对象的,另一个是自己类的对象的。
  • 对于同步代码块,要清楚什么对象被锁定,即synchronized后面括号里面的参数,在同一个对象上进行同步的线程将彼此阻塞,不同对象锁定的线程将永远不会阻塞。

何时需要同步?

在多个线程同时访问互斥(可交换)数据时,应该同步以保护数据,确保两个线程不会同时修改它。

  • 对于非静态字段中可更改的数据,通常使用非静态方法访问。
  • 对于静态字段中可更改的数据,通常使用静态方法访问,仔细想也明白,针对这类数据如果用平常的方法同步的话,假设该类同时有好几个对象,再来个不同的线程一个操作一个对象,,这样的话他们就能同时篡改这个静态变量了。。白瞎。。所以真正的保护是写个同步的静态方法去操作这个静态数据,,因为拿到的锁是针对这个类的锁,这样在同一个进程中,所有该类的对象在运行到持有类锁的同步代码时,就可以全部被封死了。
  • 如果需要在非静态方法中使用静态字段,或者静态字段中使用非静态方法,问题就变得非常复杂了。。

线程安全的类

当一个类已经很好的同步以保护它的数据时,这个类就称为线程安全的。但是即使是线程类,使用起来就一定线程安全吗?不一定,应该特别小心的用,因为操作的线程间仍然不一定安全。

举个例子,其实这种例子在开发工作中也遇到过类似的:

比如一个集合是线程安全的,有两个线程操作同一个集合对象,当一个线程查询集合非空后,删除集合中所有的元素,第二个线程也来执行与第一个线程相同的操作,也许在第一个线程查询后,第二个线程也查询出了集合非空,但是当第一个线程执行清除后,第二个再执行删除明显是不对的,因为此时集合已经为空了。。这种现象,可以说是即使你保证了原子级操作的线程安全性,但是原子与原子间究竟发生了什么是不可得知的,更何谈其之间多线程操作的安全性。 那么这种情况解决方法是 线程对集合操作的时候,对集合加同步锁,锁是集合的锁。就可以解决。但是这种解决方式在一定程度上牺牲了并发所带来的性能优势。

死锁问题

死锁对于java程序来说是很复杂的,也很难发现问题。当两个线程被阻塞,每个线程在等待另一个线程的时候就会发生死锁。前面我们有提到一个事情, 同步代码块中的逻辑,即使一个线程sleep了,也不会释放它持有的锁。

举一个牵强的例子:

package Thread;

public class SiSuoDemo {
	
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		A a = new A();
		a.age = 0;
		a.name = "name";
		a.sex = 1;
		
		B b = new B();
		
		A2BThread a2bThread = new A2BThread(a, b);
		B2AThread b2aThread = new B2AThread(a, b);
		
		a2bThread.start();
		b2aThread.start();
	}
}

class A {
	public String name = "";
	public int age = 0;
	public int sex = 0;
	
	public String toString() {
		return "now is A and name = " + name + " age = " + age + " sex = " + (sex == 0 ? "男" : "女");
	}
}

class B extends A {
	public String adress = "";
	public String phoneNumber = "";
	
	public String toString() {
		return "now is B and name = " + name + " age = " + age + " sex = " + (sex == 0 ? "男" : "女") 
				+ " adress = " + adress + " phonenumber = " + phoneNumber;
	}
}

class B2AThread extends Thread{
	private A a = null;
	private B b = null;
	public B2AThread(A a, B b) {
		this.a = a;
		this.b = b;
	}
	
	public void run(){
		try {
			synchronized (b) {
				Thread.sleep(1);	//此时强制睡了1毫秒,意在使其切换另一个线程,要记得即使sleep了,该线程持有的锁是不会释放的
				synchronized (a) {
					if (b != null && a != null) {
						a.name = b.name;
						a.age = b.age;
						a.sex = b.sex;
						System.out.println(a);
					}
				}
			}
		} catch (Exception e) {
			// TODO: handle exception
		}
	}
}

class A2BThread extends Thread{
	private A a = null;
	private B b = null;
	public A2BThread(A a, B b) {
		this.a = a;
		this.b = b;
	}
	
	public void run() {
		try {
			synchronized (a) {
				Thread.sleep(1); //此时强制睡了1毫秒,意在使其切换另一个线程,要记得即使sleep了,该线程持有的锁是不会释放的
				synchronized (b) {
					if (b != null && a != null) {
						b.adress = "xxx";
						b.phoneNumber = "xxx";
						b.age = a.age;
						b.name = a.name;
						b.sex = a.sex;
						System.out.println(b);
					}
				}
			}
		} catch (Exception e) {
			// TODO: handle exception
		}
	}
}

运行结果是什么都没打印出来。因为死锁了。两个线程都因为得不到自己期待的锁,都阻塞着不往下走。有没有注意到 里面很牵强的加上了sleep(), 这句来模拟一种很特殊的情况,即,恰好一方刚获取了两个锁的第一个锁的时候,切换线程了!

让两个 A2BThread 获得了a的锁之后,就立马睡眠使其阻塞,这样就会很快的切到另一个线程了,然后另一个线程,也就是B2AThread,在执行run方法的时候,首先获取了b的锁,然后又睡,,这样就继续执行 A2BThread,,下一行,A2BThread就要索要a的锁了,,但是刚才B2A线程已经获取了a的锁了,,所以 A2BThread一定会获取失败的,那就阻塞吧,等别的线程把a的锁还回来就可以继续运行了,于是阻塞又切回了 B2A,,但是B2A的下一行就是获取 a 的锁,但是此时a这个锁正在被A2B这个阻塞线程持有着呢,是不会得到这个锁的,于是B2A也采取阻塞的方式以等待另一个线程释放。。。于是就进入了一个逻辑死循环中,,二者永远也得不到自己想要的锁,因为二者都正在持有着他们需要的锁(线程都阻塞不干活)。死锁就出现了。

但是如果不用sleep方法导致且线程的话,,实际情况下是不太容易出现这种一行都打印不出来的结果的,,,当然,去掉sleep()后,可能输出的结果是对的,也可能是不对的。

但是无论代码中发生死锁的概率有多小,一旦发生死锁,程序就会死掉。简直是毁灭性的灾难。有一些方法能帮助避免死锁,包括始终按照预定义的顺序获取锁这一策略。自行百度吧,,困死我了。。

线程同步小结

  • 线程同步的目的是为了保护多个线程访问同一个资源时对资源的破坏。
  • 线程同步方法是通过锁来实现的,每个对象有且只有一个锁,这个锁与其对应的对象关联,线程一旦获得了对象锁,其他访问该对象的线程就因为拿不到锁无法再访问该对象其他的同步方法。
  • 对于静态同步方法,线程所获取的锁是针对这个类的锁,锁对象是class对象。静态和非静态 同步方法的锁互不干预。一个线程获得锁,当在一个同步方法中方位另外一个对象的同步方法的时候,就会顺带获取后者对象的锁。于是就有两个锁。
  • 对于同步,要清楚是在哪个对象上同步的,这个十分关键,因为这个决定了到底是哪把锁,是不是同一把锁!
  • 编写线程安全的类,需要时刻注意对多个线程竞争访问资源的逻辑和安全作出正确的判断,对原子操作作出分析,并保证原子操作期间别的线程无法访问竞争资源。
  • 当多个线程等待一个对象锁时,没有获得到锁的线程将会发生阻塞。
  • 死锁是线程间相互等待锁造成的,在实际情况下发生的概率很小,真让你写一个死锁的程序,也不一定会好使,但是一旦程序发生了死锁,程序会挂掉。

你可能感兴趣的:(Java基础)