设计模式前篇——多线程同步synchronized、volatile详解

    在看设计模式的时候,发现很多细碎的知识点,而且还都是很基础的,如果不嚼烂的话,设计模式看起来也很迷糊,为此本人在写之前把一些学习设计模式需要知道的知识点做一个总结写成博客,这个会不断地更新,因为本人也是在不断学习的哈哈。下面进入正题。

Java线程通信机制

首先给大家带来一张良心作图(很认真的在画了,,,,):


这里解释以上的几个名词:

  • 主内存:主内存中存储着我们的所有变量(这里只对于静态变量static)
  • 本地内存:本地内存是一个抽象的内存概念,实际上并不存在这么一个实物。它涵盖了缓存、缓冲区、寄存器、硬件以及编译器优化。

这张图就是对不同线程间通信的抽象表示:在我们主内存中保存着所有变量,而每创建一个线程,会为其开辟一个所谓的本地内存,将主内存中的变量拷贝到本地内存中,当我们对变量操作完成之后,会将值传回给主内存,进行值得更新。

以上是一种默认的优化的情况。接下来有几个典型的案例。

几个典型案例

案例1:本地内存值没来得及更新

上面我们说到了Java中多线程情况下的数据交互,下面针对上面这种情况我们进行模拟:

public class SingleText {

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub
		Thread thread1 = new Thread(new ThreadSingleton());
		Thread thread2 = new Thread(new ThreadSingleton());
		thread1.start();
		thread2.start();
	}

}

class ThreadSingleton implements Runnable {

	private static int sum = 0;

	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			//
			sum++;
			System.out.println(Thread.currentThread().getName() + "   sum =  " + sum);
			//

		}
	}
}

我们创建了两个线程,thread1和thread2,每个线程执行的操作都是让静态变量sum的值+1。

根据我们理想的输出,应该是1,2,3,4,5.。。。。。。。20,可能顺序不会一样,但是一定是从1到20都有的,我们接下来打印几组结果:

                    

第一组结果应该还算是比较理想的,只是由于线程抢占资源导致打印的顺序发生了变化。

但是第二组结果我们就不太满意了,同时出现了两个2,而1却没有了。

解释一下:由于我们上面说到在本地内存进行数据操作之后,将操作的数据返回给主内存进行更新数据,那么有这样一个问题:如果说在1的数据还没有来得及在主内存中更新,而2就已经进行了操作(上面案例,同时打印出了两个2)。对于这种结果,我想我们是不能接受的。

如果上面模拟不是很明显,那我们看一下这个。

案例2:同时进行操作,导致结果发生错误。

现在我们将代码修改为这样:

public class SingleText {

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub
		Thread thread1 = new Thread(new ThreadSingleton());
		Thread thread2 = new Thread(new ThreadSingleton());
		thread1.start();
		thread2.start();
	}

}

class ThreadSingleton implements Runnable {

	private static volatile int sum = 0;

	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			sum++;
			if (sum == 10) {
				System.out.println("我= 10了");
				System.out.println("sum =  " + sum);
			} else {
				System.out.println("我 != 10");
			}
		}
	}
}

只是修改了Thread中的run方法,如果sum到达10了,就会输出提示并且将sum的值输出。

理论上来说,它输出的sum值肯定为10,但真的是这样吗?几组结果如下:

我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我= 10了
sum =  10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我= 10了
sum =  16
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我 != 10
我= 10了
sum =  19
我 != 10

第一组结果输出的是sum = 10,而后面两个结果是16和19。

解释一下:由于有两个线程同时对sum进行++操作,在一个线程中sum到达10时候,另一个线程同时也在操作sum++,可能会导致还没来得及打印就已经将sum++。

以上的两个案例都是针对于多线程中数据交互时发生的错误,可能会导致线程非安全。而synchronized和volatile两个关键字可以解决上述的问题。

synchronized

synchronized为同步锁的意思,他修饰在类的实例方法中、静态方法中、或者一个代码块。每个synchronized锁会锁住一个对象,而每个对象的对象锁只有一个。当当前线程进入sychronized修饰的部分时,如果获取了对象的同步锁,那么就会执行这些代码,而如果没有获取到对象的同步锁,那么则会阻塞。下面分被对这三种情况进行说明:

1.synchronized修饰实例方法:

用synchronized修饰实例方法,锁对象为该实例方法所在类的.Class对象。在线程执行到该方法时会先获取同步锁,如果获取同步锁则执行方法,若同步锁被占用,则获取失败,阻塞。

public class SingleText {

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub

		Text t = new Text();
		new Thread(new Runnable() {

			@Override
			public void run() {
				// TODO Auto-generated method stub
				t.add();
			}
		}).start();

		new Thread(new Runnable() {

			@Override
			public void run() {
				// TODO Auto-generated method stub
				t.add();
			}
		}).start();
	}

}

class Text {
	public static int sum = 0;

	public synchronized void add() {
		for (int i = 0; i < 10; i++) {
			sum++;
			System.out.println(Thread.currentThread().getName() + "  sum = " + sum);
		
		}
		
	}
}

如上述例子,add方法为Text类中的一个实例方法,此时synchronized锁住的对象为Text实例化的对象:t。

现在我们对比一下add方法添加synchronized和没有添加的打印结果:

Thread-0  sum = 1
Thread-0  sum = 2
Thread-0  sum = 3
Thread-0  sum = 4
Thread-0  sum = 5
Thread-0  sum = 6
Thread-0  sum = 7
Thread-0  sum = 8
Thread-0  sum = 9
Thread-0  sum = 10
Thread-1  sum = 11
Thread-1  sum = 12
Thread-1  sum = 13
Thread-1  sum = 14
Thread-1  sum = 15
Thread-1  sum = 16
Thread-1  sum = 17
Thread-1  sum = 18
Thread-1  sum = 19
Thread-1  sum = 20

这是添加synchronized。

Thread-0  sum = 1
Thread-0  sum = 2
Thread-0  sum = 4
Thread-1  sum = 4
Thread-1  sum = 6
Thread-0  sum = 5
Thread-0  sum = 8
Thread-1  sum = 8
Thread-0  sum = 9
Thread-0  sum = 11
Thread-0  sum = 12
Thread-0  sum = 13
Thread-0  sum = 14
Thread-1  sum = 10
Thread-1  sum = 15
Thread-1  sum = 16
Thread-1  sum = 17
Thread-1  sum = 18
Thread-1  sum = 19
Thread-1  sum = 20

这是没有添加synchronized,我们看到有两个4打印出来了。原因不解释了,如果大家不明白的话请看上面。

我们说synchronized现在是修饰的实例方法,它锁住的对象时实例话的Text对象t,那么我现在在创建一个Text对象t2,让线程1执行t的add方法,线程2执行t2的add方法,看看结果会是什么样的(这个结果运行了半天才得到额。。。):

Thread-0  sum = 1
Thread-0  sum = 2
Thread-1  sum = 4
Thread-0  sum = 4
Thread-0  sum = 6
Thread-1  sum = 5
Thread-0  sum = 7
Thread-1  sum = 8
Thread-0  sum = 9
Thread-0  sum = 11
Thread-0  sum = 12
Thread-0  sum = 13
Thread-1  sum = 10
Thread-0  sum = 14
Thread-1  sum = 15
Thread-1  sum = 16
Thread-1  sum = 17
Thread-1  sum = 18
Thread-1  sum = 19
Thread-1  sum = 20

同样出现了问题,这说明修饰实例方法的synchronized锁住的对象为对应的实例对象。


2.synchronized修饰静态方法

这个跟上面修饰实例方法的效果差不多,被synchronized修饰的静态方法,锁对象为静态方法所在的类的.Class对象。当线程执行到该方法时,会先判断是否获取同步锁,如果获取成功则执行该方法,若锁被占用则获取失败,阻塞。

我们来看一下这个例子:

public class SingleText {

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub

		new MyThread().start();
		new MyThread().start();
	}

}

class MyThread extends Thread {

	private static int sum = 0;

	public static synchronized void add() {
		for (int i = 0; i < 10; i++) {
			sum++;
			System.out.println(Thread.currentThread().getName() + "  sum = " + sum);
		}

	}

	@Override
	public void run() {
		// TODO Auto-generated method stub
		add();
	}
}

这是添加同步锁的静态方法add,所以他的执行是不会出现问题的。不信的话大家可以多试几遍-。+

我们下面直接将有synchronized修饰和没有synchronized修饰的结果一同打印出来了:

Thread-0  sum = 1
Thread-0  sum = 2
Thread-0  sum = 3
Thread-0  sum = 4
Thread-0  sum = 5
Thread-0  sum = 6
Thread-0  sum = 7
Thread-0  sum = 8
Thread-0  sum = 9
Thread-0  sum = 10
Thread-1  sum = 11
Thread-1  sum = 12
Thread-1  sum = 13
Thread-1  sum = 14
Thread-1  sum = 15
Thread-1  sum = 16
Thread-1  sum = 17
Thread-1  sum = 18
Thread-1  sum = 19
Thread-1  sum = 20
Thread-0  sum = 2
Thread-1  sum = 2
Thread-0  sum = 3
Thread-0  sum = 5
Thread-1  sum = 4
Thread-1  sum = 7
Thread-0  sum = 6
Thread-1  sum = 8
Thread-0  sum = 9
Thread-0  sum = 11
Thread-0  sum = 12
Thread-0  sum = 13
Thread-1  sum = 10
Thread-0  sum = 14
Thread-1  sum = 15
Thread-1  sum = 17
Thread-1  sum = 18
Thread-0  sum = 16
Thread-1  sum = 19
Thread-1  sum = 20

3.synchronized修饰代码块

这个我们一般情况下会选用这样的方式去添加同步(原因等下再说)。这个跟上面两种同样道理,只不过上面是将一个方法放入,而第三种情况是将一个代码块放入。

在这种情况下我们锁对象是由自己决定的,当线程只想到对应代码块位置时,首先判断是否获取同步锁,如果获取则执行代码块,反之阻塞。

下面我们通过第三种方式改写前两种的代码:

class MyThread extends Thread {

	private static int sum = 0;

	public void add() {
		synchronized (this) {
			for (int i = 0; i < 10; i++) {
				sum++;
				System.out.println(Thread.currentThread().getName() + "  sum = " + sum);
			}
		}
		

	}

	@Override
	public void run() {
		// TODO Auto-generated method stub
		add();
	}
}

其他的地方都一样,所以我就只粘贴MyThread了。

首先这种情况下add为一个实例方法,那么在synchronized中添加this则表示创建该MyThread类的对象。


class MyThread extends Thread {

	private static int sum = 0;

	public static void add() {
		synchronized (MyThread.class) {
			for (int i = 0; i < 10; i++) {
				sum++;
				System.out.println(Thread.currentThread().getName() + "  sum = " + sum);
			}
		}
		

	}

	@Override
	public void run() {
		// TODO Auto-generated method stub
		add();
	}
}

这种情况下add为一个静态方法,我们在synchronized中写入MyThread.class,则表示MyThread类的Class对象,同第二种情况一样。

当然除了这两种写法之外,我们还有其他的写法,因为同步代码块中锁住的是一个对象,所以只要填入一个对象就可以。(但是切记,不要自己给自己挖坑。。。)

结果就不粘贴了,跟前两个一样的。


新的问题

重排序问题

在执行程序中,为了提高性能,可能会指令进行重排序(指令可以理解为我们的代码)。

关于重排序笔者这里也没有了解很多,就举一个很简答的栗子来说吧:假如我们new一个对象,正常的顺序应该为:

  1. new关键字,分配内存
  2. 调用构造方法初始化参数
  3. 将引用只想这块内存。

默认的顺序应该是1->2->3,但是经过重排序之后,可能执行顺序变为了3->1->2。

这样情况不会影响单个线程执行,但是会影响程序执行的并发性。也就是说,在一个线程中最后的结果跟原来的一样,但是多个线程情况下,由于顺序的改变会是其他线程产生混乱:

A线程先执行了3,但此时还没有分配内存,在B中看到为null,所以又分配了一块内存给他,这样就创建了两个对象。我这么说应该能理解了吧。

所以说只有一个synchronized看来还是不够的,所以在j5之后,又出了一个黑科技,那就是volatile关键字,为解决以上问题而生的。

volatile

volatile针对的以上两种问题,所以它的作用可以分为两个:

  1. 使变量具有可见性:被volatile修饰的变量,会保证值立即更新到主内存中,其他线程在读取时,也是获取到更新后的值。我们可以理解为直接修改主内存中的数据。
  2. 禁止重排序。这个没什么好说的了,当我们对volatile修饰的变量进行操作时,它之前的步骤肯定完成,而之后的步骤肯定没有进行。
关于volatile的例子没有什么可举的了,放到下面和synchronized一起说。


总结

1.synchronized既能保证可见性,又能保证原子性

可见性体现在:在同一时刻被synchronized修饰的代码,只有获取同步锁的线程会执行该代码,而会在释放锁之前将属性值同步到主内存中。

原子性体现在:要么执行,要么不执行阻塞。

2.synchronized尽量修饰代码块,而不是同步方法,这样可以降低锁的粒度

简单提一下,在锁之前,能做什么尽量做什么。让锁内的东西执行外后就释放掉锁,不要让锁过长时间的占用。

3.volatile只能保证可见性,不能保证原子性,但在某些情况下可见性比synchronized性能优越

可见性体现在:保证被volatile修饰的属性立刻同步到主内存中。

4.但是volatile无法替代synchronized,因为其不能保证原子性

所以针对上面的案例,一下写法是最安全的:

public class SingleText {

	public static void main(String[] args) throws InterruptedException {
		// TODO Auto-generated method stub

		new MyThread().start();
		new MyThread().start();
	}

}

class MyThread extends Thread {

	private static volatile int sum = 0;

	public static void add() {
		synchronized (MyThread.class) {
			for (int i = 0; i < 10; i++) {
				sum++;
				System.out.println(Thread.currentThread().getName() + "  sum = " + sum);
			}
		}
	}

	@Override
	public void run() {
		// TODO Auto-generated method stub
		add();
	}
}

今天就到这里啦,喜欢的朋友希望关注一波,如果有不同的意见可以在下方留言。

感谢大家的支持!!


猜你喜欢

转载自blog.csdn.net/zy_jibai/article/details/80719137