一个简单的并发问题
package cn.kerninventor.demo.multithread.sync;
/**
* @author kern
*/
public class TestCase {
private int number = 0;
public void addNumber() {
for (int i = 0; i < 100000; i++) {
number = number + 1;
}
}
public static void main(String[] args) throws Exception {
TestCase testCase = new TestCase();
Thread threadA = new Thread(testCase::addNumber);
Thread threadB = new Thread(testCase::addNumber);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("number=" + testCase.number);
}
}
两个线程同时对一个整数变量做加操作,理想情况下应该等于200000,但是执行如上代码你会发现,结果几乎永远没办法如预期。
分别执行三次,结果三次结果都不同。
这是一个比较简单的并发问题。
缓存带来的并发问题
把问题从更细的层面去观察有助于我们更好的理解。
因此我利用编译与反编译工具,针对一次自增操作来查看jvm的做法
package cn.kerninventor.demo.multithread.sync;
/**
* @author kern
*/
public class Demo {
public static void main(String[] args) {
int i = 0;
i = i + 1;
}
}
如上代码,对一个整数变量做了一次自增操作。
kern@huangjiiandeMBP sync % javac Demo.java
kern@huangjiiandeMBP sync % javap -c Demo.class
Compiled from "Demo.java"
public class cn.kerninventor.demo.multithread.sync.Demo {
public cn.kerninventor.demo.multithread.sync.Demo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_0 // 常量0压入栈顶
1: istore_1 // 存储变量i到局部变量表中1的位置
2: iload_1 // 加载局部变量表中1位置的变量到栈顶 第一步
3: iconst_1 // 加载常量1到栈顶
4: iadd // 加操作 第二步
5: istore_1 // 存储变量i到局部变量表中1的位置 第三步
6: return // 结束
}
kern@huangjiiandeMBP sync %
除去变量的声明过程,一次自增操作被分为四次jvm指令操作,逻辑上分为三步,取数,加总,存数。尽管每个jvm指令是不可分割的,也就是一个原子性操作,但是多个原子性操作组成的程序却不具备原子性。因而在多线程环境下可能导致线程间数据的不同步。
通过以下示意图来加以解释
**
**
在上述情况中,i ++ 操作由于不具备原子性,因而导致了不同工作内存中的变量副本的修改没有及时刷新到主存,导致了程序处理结果的错误。此外还有多种情况可能导致一样的问题,例如某个线程在修改了工作内存中的变量后由于刷新到主存的时机没有强制限制,因而在另外的线程从主存中获取了变量后才刷新到主存中,也同样会导致一样的问题。
利用synchronized 解决一致性问题
synchronized 关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误,如果一个对象对多个线程是可见的,那么对该对象的所有读或者写都将通过同步的方式来进行。
这是java官方对synchronized关键字的解释,简单来说就是利用在程序间加锁,使得多个线程在程序执行时串行化。多个线程在执行被synchronized关键字修饰的方法或者代码块时,需要竞争一个锁资源,一个锁资源只能被一个线程持有,其他线程则进入blocked状态等待锁资源的释放。
synchronized关键字可以修饰方法,或者代码块,针对上述案例中的问题,我们可以以利用synchronized关键字以两种编程方式来解决。
package cn.kerninventor.demo.multithread.sync;
/**
* @author kern
*/
public class TestCase {
private int number = 0;
/**
* 同步方法
* synchronized关键字修饰方法使得整个方法的执行作为一个原子性操作
*/
public synchronized void addNumber() {
for (int i = 0; i < 100000; i++) {
number = number + 1;
}
}
public static void main(String[] args) throws Exception {
TestCase testCase = new TestCase();
Thread threadA = new Thread(testCase::addNumber);
Thread threadB = new Thread(testCase::addNumber);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("number=" + testCase.number);
}
}
package cn.kerninventor.demo.multithread.sync;
/**
* @author kern
*/
public class TestCase {
private int number = 0;
// /**
// * 同步方法
// * synchronized关键字修饰方法使得整个方法的执行作为一个原子性操作
// */
// public synchronized void addNumber() {
// for (int i = 0; i < 100000; i++) {
// number = number + 1;
// }
// }
//
public void addNumber() {
for (int i = 0; i < 100000; i++) {
/**
* 同步方法块
* synchronized关键字修饰代码块使得i++操作作为一个原子性操作
*/
synchronized (this) {
number = number + 1;
}
}
}
public static void main(String[] args) throws Exception {
TestCase testCase = new TestCase();
Thread threadA = new Thread(testCase::addNumber);
Thread threadB = new Thread(testCase::addNumber);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("number=" + testCase.number);
}
}
两种方式执行的结果都将使得程序有一个正确的结果。尽管如此,我们还是可以很容易看到,两者还是有部分区别的,在程序执行粒度上,同步代码块通过显示声明锁资源的对象以代码块的形式包围某段程序,相较同步方法块有更细的程序执行粒度,例如上述中同步代码块可以控制到使得 i++ 作为一个原子性操作,而同步方法只能令整个for循环作为一个原子性操作。因而我们可以说,同步代码块相较而言更加灵活。这完全取决于你期望的程序执行效果。
此外,两者孰优孰劣却不能一概而论。以上述编程而言,同步代码块显然需要在线程上下文的切换消耗更多的cpu资源,同时意味着更多的加锁解锁的资源消耗,我们可以将程序简单修改如下,并分别注释执行得到如下结果
package cn.kerninventor.demo.multithread.sync;
import java.util.LinkedList;
import java.util.List;
import java.util.Stack;
/**
* @author kern
*/
public class TestCase {
private int number = 0;
/**
* 同步方法
* synchronized关键字修饰方法使得整个方法的执行作为一个原子性操作
*/
// public synchronized void addNumber() {
// for (int i = 0; i < 100000; i++) {
// long currentThreadId = Thread.currentThread().getId();
// if (currentThreadId != threadContextChangedTimeRecord.peek()) {
// threadContextChangedTimeRecord.push(currentThreadId);
// }
// number = number + 1;
// }
// }
// 记录线程的切换次数
Stack<Long> threadContextChangedTimeRecord = new Stack<>();
{
// 赋初始值以简化后面程序的编写,避免多次判断
threadContextChangedTimeRecord.add(-1L);
}
public void addNumber() {
for (int i = 0; i < 100000; i++) {
/**
* 同步方法块
* synchronized关键字修饰代码块使得i++操作作为一个原子性操作
*/
synchronized (this) {
long currentThreadId = Thread.currentThread().getId();
if (currentThreadId != threadContextChangedTimeRecord.peek()) {
threadContextChangedTimeRecord.push(currentThreadId);
}
number = number + 1;
}
}
}
public static void main(String[] args) throws Exception {
TestCase testCase = new TestCase();
Thread threadA = new Thread(testCase::addNumber);
Thread threadB = new Thread(testCase::addNumber);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("number=" + testCase.number);
System.out.println("thread context changed time=" + (testCase.threadContextChangedTimeRecord.size() - 2));
}
}
分别执行后如下结果,同步方法只经历1次线程的切换,同步方法块却进行了40000倍以上的次数的线程切换。
因此,尽管在灵活度上有巨大的优势,但往往更细的粒度意味着更多的线程上下文切换成本。尽管java本身能够提供锁粗化的机制来优化这种情况下的执行效率,但在本例中显然同步方法是更实惠的方式。
synchronized 能保证可见性和有序性吗?
synchronized
利用锁机制使得多个线程间对一段内存区域的访问串行化,以锁的持有和释放保证单位时间内只有一个线程能够访问该内存区域,以此保证了程序的原子性。其外关于多线程编程的其他三要素,可见性和有序性问题,synchronized
也是能够进行保证的。
synchronized
在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。
synchronized
是能够保证有序性的。根据as-if-serial语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。而synchronized
保证了单线程独占CPU,也就保证了有序性。
synchronized关键字的实现原理
关于synchronized关键字的实现原理,有几篇非常不错的文章已经讲解得非常明白了,这里推荐给大家
来自知乎: https://zhuanlan.zhihu.com/p/114132797 深入剖析synchronized关键字的底层原理
来自博客园: https://www.cnblogs.com/lykm02/p/4516777.html Java synchronized 关键字的实现原理
来自博客园:https://www.cnblogs.com/aspirant/p/11470858.html 深入分析Synchronized原理(阿里面试题)
来自博客园:https://www.cnblogs.com/aspirant/p/8657681.html 解决多线程安全问题-无非两个方法synchronized和lock 具体原理以及如何 获取锁AQS算法 (百度-美团)