Java多線程編程核心技術(二)對象及變量的並發訪問
最近一直在忙比賽,四五個吧,時間有點緊張,博客也沒時間更新~ 只能忙裏抽閑
本文屬於Java多線程編程系列的第二篇,旨在分享我對多線程編程技術的心得與感悟,順便做下筆記。
如果你閱讀完比較感興趣,歡迎關註我,等待更新後續篇章。
本文主要介紹Java多線程中的同步,也就是如何在Java語言中寫出線程安全的程序,如何在Java語言中解決非線程安全的相關問題。
1.synchronized同步方法
“非線程安全”其實會在多個線程對同一個對象中的實例變量進行並發訪問時發生,產生的後果就是”臟讀“,也就是讀取到的數據其實是被更改過的。而“線程安全”就是已獲得的實例變量的值是經過線程同步處理的,不會出現臟讀的現象。
1.1 方法內的變量為線程安全
“非線程安全”問題存在於“實例變量”中,如果是方法內部的私有變量,則不存在“非線程安全”問題,所得結果也就是“線程安全”的了。
1.2 實例變量的非線程安全
如果多個線程共同訪問1個對象中的實例變量,則有可能出現“非線程安全”問題。
用線程訪問的對象中如果有多個實例對象,則運行的結果有可能出現交叉的情況。
如果對象僅有一個實例變量,則有可能出現覆蓋的情況。
如果兩個線程同時訪問一個沒有同步的方法,如果兩個線程同時操作業務對象中的實例變量,則有可能出現“非線程安全”問題。解決這個問題的方法就是在方法前加關鍵字synchronized即可。
1.3 多個對象多個鎖
代碼示例:
public class Run { public static void main(String[] args) { MyService service1 = new MyService(); Thread thread1 = new Thread(service1); thread1.start(); MyService service2 = new MyService(); Thread thread2 = new Thread(service2); thread2.start(); } } public class MyService implements Runnable { private int i = 0; @Override synchronized public void run() { System.out.println(++i); } }
上面示例是兩個線程分別訪問同一個類的兩個不同實例的相同的同步方法,效果卻是以異步的方式運行的。本示例由於創建了2個業務對象,在系統中產生出2個鎖,所以運行結果是異步的,打印的效果就是1 1。當我們把線程2的參數service2改成service1,打印結果變為1 2。為什麽是這樣的結果?
關鍵字 synchronized 取得的線程對象都是對象鎖,而不是把一段代碼或方法(函數)當做鎖,所以在上面的示例中,哪個線程先執行帶 synchronized 關鍵字的方法,哪個線程就持有該方法所屬對象的鎖Lock,那麽其他線程只能呈等待狀態,前提是多個線程訪問的是同一個對象。
但如果多個線程訪問多個對象,則JVM會創建多個鎖。
1.4 synchronized方法與鎖對象
為了證明前面講的線程鎖是對象,示例代碼如下:
public class MyService implements Runnable {
@Override
public void run() {
System.out.println("begin: "+Thread.currentThread().getName());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}
}
public class Run {
public static void main(String[] args) {
MyService service = new MyService();
Thread thread1 = new Thread(service,"A");
thread1.start();
Thread thread2 = new Thread(service,"B");
thread2.start();
}
}
運行結果:
begin: B
begin: A
end
end
在run方法前加入關鍵字synchronized進行同步處理。再次運行結果如下:
begin: A
end
begin: B
end
通過上面的實驗得出結論,調用關鍵字synchronized聲明的方法一定是排隊運行的。另外需要牢牢記住“共享”這兩個字,只有共享資源讀寫訪問才需要同步化,如果不是共享資源,那麽基本就沒有同步的必要。
1.5 臟讀
public class MyService{
private String username = "AA";
private String password = "aa";
public void getValue() {
System.out.println(Thread.currentThread().getName()+" : "+username+" "+password);
}
synchronized public void setValue(String username,String password){
this.username = username;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.password = password;
}
public static void main(String[] args) throws InterruptedException {
MyService service = new MyService();
Thread thread1 = new Thread(() -> service.setValue("BB","bb"),"Thread-A");
thread1.start();
Thread.sleep(200);
Thread thread2 = new Thread(service::getValue,"Thread-B");
thread2.start();
}
}
打印結果:
Thread-B : BB aa
出現臟讀是因為getValue方法不是同步的,所以可以在任意時候進行調用。解決方法就是加上同步synchronized關鍵字,代碼如下:
synchronized public void getValue() {
System.out.println(Thread.currentThread().getName()+" : "+username+" "+password);
}
運行結果:
Thread-B : BB bb
通過上述示例不僅要知道臟讀是通過synchronized關鍵字解決的,還要知道如下內容:
當A線程調用實例對象的加入synchronized關鍵字的 X 方法時,A線程就獲得了 X 方法鎖,更準確地講,是獲得了對象的鎖,所以其他線程必須等A線程執行完畢了才可以調用 X 方法,但B線程可以隨意調用其他的非 synchronized 同步方法。
臟讀一定會出現操作實例變量的情況下,這就是不同線程“爭搶”實例變量的結果。
1.6 synchronized鎖重入
關鍵字synchronized擁有鎖重入的功能,也就是在使用synchronized時,當一個線程得到一個對象後,再次請求此對象鎖時是可以再次得到該對象的鎖的。這也證明了在一個synchronized方法/塊的內部調用本類的其他synchronized方法/塊,是永遠可以得到鎖的。
示例代碼:
public class MyService{
synchronized public void service1(){
System.out.println("service1");
service2();
}
synchronized public void service2(){
System.out.println("service2");
}
}
“可重入鎖”的概念是:自己可以再次獲取自己的內部鎖。可重入鎖也支持在父子類繼承的環境中。
示例代碼:
public class MyServiceChild extends MyService{
synchronized public void service(){
System.out.println("service1");
this.service2();
}
}
說明子類是完全可以通過“可重入鎖”調用父類的同步方法的。
1.7 出現異常,鎖自動釋放
當一個線程執行的代碼出現異常時,其所持有的鎖會自動釋放。
1.8 同步不具有繼承性
同步不可以繼承。子類繼承父類的同步方法時還需要添加synchronized關鍵字才能保持同步。
2.synchronized同步語句塊
用關鍵字synchronized聲明方法在某些情況下是有弊端的,比如A線程調用同步方法執行一個長時間的任務,那麽B線程則必須等待比較長的時間。在這樣的情況下可以使用synchronized同步語句塊來解決。synchronized 方法是對當前對象進行加鎖,而 synchronized代碼塊是對某一個對象進行加鎖。
2.1 synchronized同步代碼塊的使用
當兩個並發線程訪問同一個對象object中的synchronized(this)同步代碼塊時,一段時間內只能有一個線程被執行,另一個線程必須等待當前線程執行完這個代碼塊以後才能執行該代碼塊。
示例代碼:
public class Test {
public void service(){
synchronized (this) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537000799741
Thread-A end: 1537000802742
Thread-B begin: 1537000802742
Thread-B end: 1537000805742
上述示例證明了同步synchronized代碼塊真的是同步的。
2.2 一半同步,一半異步
我們把前面的示例代碼的service方法改造一下:
public void service(){
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
synchronized (this) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
再次運行:
Thread-A begin: 1537001008952
Thread-B begin: 1537001008952
Thread-A end: 1537001011953
Thread-B end: 1537001014954
本實驗說明:不在synchronized代碼塊中就是異步執行,在synchronized塊中就是同步執行。
2.3 synchronized代碼塊間的同步性
在使用synchronized(this)代碼塊需要註意的是,當一個線程訪問object的一個synchronized(this)同步代碼塊時,其它線程對同一個object中所有其他synchronized(this)同步訪問被阻塞,這說明synchronized使用的“對象監視器”是一個。
和synchronized關鍵字修飾的方法一樣,synchronize(this)代碼塊也是鎖定的當前對象。
2.4 將任意對象作為對象監視器
多個線程調用同一個對象中得不同名稱的synchronized同步方法或synchronized(this)同步代碼塊時,調用的效果就是按順序執行,也就是同步的,阻塞的。
這說明synchronized同步方法或synchronized同步代碼塊分別有兩種作用。
(1)對其他synchronized同步方法或synchronized(this)同步代碼塊調用呈阻塞狀態。
(2)同一時間只有一個線程可以執行synchronized同步方法或synchronized(this)同步代碼塊中的代碼。
在前面我們使用synchronized(this)格式來同步代碼塊,其實Java還支持對“任意對象”作為“對象監視器”來實現同步的功能。這個”任意對象“大多數是實例變量及方法的參數,使用格式為synchronized(非this對象)。
根據前面對synchronized(this)同步代碼塊的作用總結可知,synchronized(非this對象)格式的作用只有1種:synchronized(非this對象 X )同步代碼塊。
(1)在多個線程持有”對象監視器“為同一個對象的前提下,同一時間只有一個線程可以執行synchronized(非this對象 X)同步代碼塊。
(2)當持有”對象監視器“為同一個對象的前提下,同一時間只有一個線程可以執行synchronized(非this對象X)同步代碼塊中的代碼。
下面演示下任意對象作為對象監視器的示例:
public class Test {
private String anyObject = new String();
public void service(){
synchronized (anyObject) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537008016172
Thread-A end: 1537008019173
Thread-B begin: 1537008019173
Thread-B end: 1537008022173
鎖非this對象具有一定的優點:如果在一個類中有很多個synchronized方法,這時雖然能實現同步,但會受到阻塞,所以影響運行效率;但如果使用同步代碼塊鎖非this對象,則synchronized(非this)代碼塊中的程序與同步方法是異步的,不與其他鎖this同步方法爭搶this鎖,則可大大提高運行效率。
再來看下面的示例代碼:
public class Test {
private String anyObject = new String();
public void service(){
synchronized (anyObject) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
synchronized public void service2(){
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service2();
}
},"Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537009027680
Thread-B begin: 1537009027681
Thread-A end: 1537009030680
可見,使用“synchronized(非this對象x)同步代碼塊”格式進行同步操作時,對象監視器必須是同一個對象,如果不是同一個對象。如果不是同一個對象監視器,運行的結果就是異步調用了,就會交叉運行。
2.5 細化三個結論
”synchronized(非this對象X)“格式的寫法是將x對象本身作為“對象監視器”,這樣就可以得出以下3個結論:
- 當多個線程同時執行synchronized(X){}同步代碼塊時呈同步效果。
- 當其他線程執行X對象中synchronized同步方法時呈同步效果。
- 當其他線程執行X對象方法裏面的synchronized(this)代碼塊時也呈現同步效果。
- 但需要註意的是,如果其他線程調用不加synchronized關鍵字的方法時,還是異步調用。
2.6 靜態同步synchronized方法與synchronized(class)代碼塊
關鍵字synchronized還可以在static靜態方法上,如果這樣寫,那是對當前的*.java文件對應的Class類進行持鎖。
下面測試靜態同步方法:
public class Test2 {
synchronized public static void service() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537011409603
Thread-A end: 1537011412608
Thread-B begin: 1537011412608
Thread-B end: 1537011415608
synchronized關鍵字加到static靜態方法上是給Class類上鎖,而synchronized關鍵字加到非static靜態方法上是給對象上鎖。
為了驗證對象鎖和Class鎖不是同一個鎖,來看下面的代碼:
public class Test2 {
synchronized public static void service() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
synchronized public void service2(){
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test2().service2();
}
}, "Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537012019151
Thread-B begin: 1537012019152
Thread-A end: 1537012022152
Thread-B end: 1537012022152
異步的原因是持有不同的鎖,一個是對象鎖,另外一個是Class鎖,Class鎖可以對所有類的實例對象起作用。
下面我們測試synchronized(class)代碼塊,示例代碼如下:
public class Test {
public void service(){
synchronized (Test.class) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service();
}
},"Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537011197190
Thread-A end: 1537011200191
Thread-B begin: 1537011200191
Thread-B end: 1537011203191
同步synchronized(class)代碼塊的作用其實和synchronized static方法的作用一樣。
2.7 數據類型String的常量池特性
在JVM中具有String常量池緩存的功能,將synchronized(String)同步塊與String聯合使用時,要註意常量池以帶來的一些例外。
public class Test {
public void service(String str){
synchronized (str) {
while (true) {
System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis());
}
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service("AA");
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service("AA");
}
},"Thread-B").start();
}
}
運行結果:
Thread-A time: 1537013470535
Thread-A time: 1537013470535
Thread-A time: 1537013470535
...
運行結果顯示A線程陷入了死循環,而B線程一直在等待未執行。出現這樣的結果就是兩個持有相同的鎖,所以造成B線程不能執行。這就是String常量池帶來的問題。因此在大多數情況下,同步synchronized代碼塊都不使用String作為鎖對象,而改用其他,比如new Object()實例化一個Object對象,但它並不放入緩存中。
改造後的代碼:
public class Test {
public void service(Object str){
synchronized (str) {
while (true) {
System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis());
}
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service(new Object());
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service(new Object());
}
},"Thread-B").start();
}
}
運行結果:
Thread-A time: 1537015931981
Thread-A time: 1537015931982
Thread-B time: 1537015931982
Thread-B time: 1537015931982
...
交替打印的原因是持有的鎖不是一個。
2.8 同步synchronized方法無限等待與解決
同步方法極易造成死循環。示例代碼:
public class Test {
synchronized public void serviceA() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
boolean is = true;
while (is){
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
synchronized public void serviceB() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.serviceA();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.serviceB();
}
}, "Thread-B").start();
}
}
線程B永遠得不到運行的機會,鎖死了。
解決的方法就是使用同步塊。更改後的代碼如下:
public class Test {
private Object objectA = new Object();
public void serviceA() {
synchronized (objectA) {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
boolean is = true;
while (is) {
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
}
private Object objectB = new Object();
synchronized public void serviceB() {
synchronized (objectB) {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
}
....
}
2.9 多線程的死鎖
Java多線程死鎖是一個經典問題,因為不同的線程都在等待根本不可能被釋放的鎖,從而導致所有的任務都無法完成。在多線程技術中,“死鎖”是必須避免的,因為這會造成線程的“假死”。
public class DealThread implements Runnable {
public String username;
public Object locak1 = new Object();
public Object locak2 = new Object();
public void setFlag(String username){
this.username = username;
}
@Override
public void run() {
if (username.equals("a")){
synchronized (locak1){
System.out.println("username:"+username);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locak2){
System.out.println("按lock1-》lock2執行");
}
}
}
if (username.equals("b")){
synchronized (locak2){
System.out.println("username:"+username);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locak1){
System.out.println("按lock2-》lock1執行");
}
}
}
}
public static void main(String[] args) throws InterruptedException {
DealThread dealThread = new DealThread();
dealThread.setFlag("a");
Thread threadA = new Thread(dealThread);
threadA.start();
Thread.sleep(100);
dealThread.setFlag("b");
Thread threadB = new Thread(dealThread);
threadB.start();
}
}
運行結果,出現死鎖:
username:a
username:b
死鎖是程序設計的Bug,在設計程序時就需要避免雙方互相持有對方的鎖的情況。需要說明的是,本實驗使用synchronized嵌套的代碼結構來實現死鎖,其實不使用嵌套的代碼結構也會出現死鎖,與嵌套不嵌套無任何關系,不要被代碼結構所誤導。只要互相等待對方釋放鎖就有可能出現死鎖。
可以使用JDK自帶的工具來檢測是否有死鎖的現象。首先進入CMD命令行界面,再進入JDK的安裝文件夾中的
bin目錄,執行jps命令。得到運行的線程Run的id值。再執行jstack命令,查看結果。
完整命令演示如下:
D:\Java\jdk1.8.0\bin>jps
8240 Launcher
13252 Jps
12312
7948 DealThread
D:\Java\jdk1.8.0\bin>jstack -l 7948
....
Java stack information for the threads listed above:
===================================================
"Thread-1":
at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:39)
- waiting to lock <0x00000000d6089e80> (a java.lang.Object)
- locked <0x00000000d6089e90> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
"Thread-0":
at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:25)
- waiting to lock <0x00000000d6089e90> (a java.lang.Object)
- locked <0x00000000d6089e80> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.
2.10 鎖對象的改變
在任何數據類型作為同步鎖時,需要註意的是,是否有多個線程同時持有鎖對象,如果同時持有鎖對象,則這些線程之間就是同步的;如果分別獲得鎖對象,這些線程之間就是異步的。
public class Test {
private String lock = "123";
public void service(){
synchronized (lock) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
lock = "456";
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
Thread.sleep(50);
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}
運行結果:
Thread-A begin: 1537019992452
Thread-B begin: 1537019992652
Thread-A end: 1537019994453
Thread-B end: 1537019994653
為什麽是亂序?因為50ms過後線程取得的鎖時“456”。
把lock = "456"放在Thread.sleep(2000)之後,再次運行。
Thread-A begin: 1537020101553
Thread-A end: 1537020103554
Thread-B begin: 1537020103554
Thread-B end: 1537020105558
線程A和線程B持有的鎖都是“123”,雖然將鎖改成了“456”,但結果還是同步的,因為A和B爭搶的鎖是“123”。
還需要提示一下,只要對象不變,即使對象的屬性被改變,運行的結果還是同步的。
3.volatile關鍵字
關鍵字volatile的主要作用是使變量在多個線程間可見。
3.1 關鍵字volatile與死循環
如果不是在多繼承的情況下,使用繼承Thread類和實現Runnable接口在取得程序運行的結果上並沒有多大的區別。如果一旦出現”多繼承“的情況,則用實現Runable接口的方式來處理多線程的問題就是很有必要的。
public class PrintString implements Runnable{
private boolean isContinuePrint = true;
@Override
public void run() {
while (isContinuePrint){
System.out.println("Thread: "+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public boolean isContinuePrint() {
return isContinuePrint;
}
public void setContinuePrint(boolean continuePrint) {
isContinuePrint = continuePrint;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(100);
System.out.println("我要停止它!" + Thread.currentThread().getName());
printString.setContinuePrint(false);
}
}
運行結果:
Thread: Thread-A
我要停止它!main
上面的代碼運行起來沒毛病,但是一旦運行在 -server服務器模式中64bit的JVM上時,會出現死循環。解決的辦法是使用volatile關鍵字。
關鍵字volatile的作用是強制從公共堆棧中取得變量的值,而不是從線程私有數據棧中取得變量的值。
3.2 解決異步死循環
在研究volatile關鍵字之前先來做一個測試用例,代碼如下:
public class PrintString implements Runnable{
private boolean isRunnning = true;
@Override
public void run() {
System.out.println("Thread begin: "+Thread.currentThread().getName());
while (isRunnning == true){
}
System.out.println("Thread end: "+Thread.currentThread().getName());
}
public boolean isRunnning() {
return isRunnning;
}
public void setRunnning(boolean runnning) {
isRunnning = runnning;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(1000);
printString.setRunnning(false);
System.out.println("我要停止它!" + Thread.currentThread().getName());
}
}
JVM有Client和Server兩種模式,我們可以通過運行:java -version來查看jvm默認工作在什麽模式。我們在IDE中把JVM設置為在Server服務器的環境中,具體操作只需配置運行參數為 -server
。然後啟動程序,打印結果:
Thread begin: Thread-A
我要停止它!main
代碼 System.out.println("Thread end: "+Thread.currentThread().getName());
從未被執行。
是什麽樣的原因造成將JVM設置為-server就出現死循環呢?
在啟動thread線程時,變量boolean isContinuePrint = true;
存在於公共堆棧及線程的私有堆棧中。在JVM設置為-server模式時為了線程運行的效率,線程一直在私有堆棧中取得isRunning的值是true。而代碼thread.setRunning(false);雖然被執行,更新的卻是公共堆棧中的isRunning變量值false,所以一直就是死循環的狀態。內存結構圖:
這個問題其實就是私有堆棧中的值和公共堆棧中的值不同步造成的。解決這樣的問題就要使用volatile關鍵字了,它主要的作用就是當線程訪問isRunning這個變量時,強制性從公共堆棧中進行取值。
將代碼更改如下:
volatile private boolean isRunnning = true;
再次運行:
Thread begin: Thread-A
我要停止它!main
Thread end: Thread-A
通過使用volatile關鍵字,強制的從公共內存中讀取變量的值,內存結構如圖所示:
使用volatile關鍵字增加了實例變量在多個線程之間的可見性。但volatile關鍵字最致命的缺點是不支持原子性。
下面將關鍵字synchronized和volatile進行一下比較:
- 關鍵字volatile是線程同步的輕量級實現,所以volatile性能肯定比synchronized要好,並且volatile只能修飾於變量,而synchronized可以修飾方法,以及代碼塊。隨著JDK新版本的發布,synchronized關鍵字在執行效率上得到很大提升,在開發中使用synchronized關鍵字的比率還是比較大的。
- 多線程訪問volatile不會發生阻塞,而synchronized會出現阻塞。
- volatile能保證數據的可見性,但不能保證原子性;而synchronized可以保證原子性,也可以間接保證可見性,因為它會將私有內存和公共內存中的數據做同步。
- 再次重申一下,關鍵字volatile解決的是變量在多個線程之間的可見性;而synchronized關鍵字解決的是多個線程之間訪問資源的同步性。
線程安全包含原子性和可見性兩個方面,Java的同步機制都是圍繞這兩個方面來確保線程安全的。
3.3 volatile非原子性的特征
關鍵字雖然增加了實例變量在多個線程之間的可見性,但它卻不具備同步性,那麽也就不具備原子性。
示例代碼:
public class MyThread extends Thread {
volatile private static int count;
@Override
public void run() {
addCount();
}
private void addCount() {
for (int i = 0;i<100;i++){
count++;
}
System.out.println(count);
}
public static void main(String[] args) {
MyThread[] myThreads = new MyThread[100];
for (int i=0;i<100;i++){
myThreads[i] = new MyThread();
}
for (int i=0;i<100;i++){
myThreads[i].start();
}
}
}
運行結果:
...
8253
8353
8153
8053
7875
7675
在addCount方法上加入synchronized同步關鍵字與static關鍵字,達到同步的效果。
再次運行結果:
....
9600
9700
9800
9900
10000
關鍵字volatile提示線程每次從共享內存中讀取變量,而不是從私有內存中讀取,這樣就保證了同步數據的可見性。但在這裏需要註意的是:如果修改實例變量中的數據,比如i++,也就是比
i=i+1,則這樣的操作其實並不是一個原子操作,也就是非線程安全。表達式i++的操作步驟分解為下面三步:
- 從內存中取i的值;
- 計算i的值;
- 將i值寫入到內存中。
假如在第二步計算i值的時候,另外一個線程也修改i的值,那麽這個時候就會臟數據。解決的方法其實就是使用synchronized關鍵字。所以說volatile關鍵字本身並不處理數據的原子性,而是強制對數據的讀寫及時影響到主內存中。
3.4 使用原子類進行i++操作
除了在i++操作時使用synchronized關鍵字實現同步外,還可以使用AtomicInteger原子類進行實現。
原子操作是不可分割的整體,沒有其他線程能夠中斷或檢查正在原子操作中的變量。它可以在沒有鎖的情況下做到線程安全。
示例代碼:
public class MyThread extends Thread {
private static AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
addCount();
}
private static void addCount() {
for (int i = 0;i<100;i++){
System.out.println(count.incrementAndGet());
}
}
public static void main(String[] args) {
MyThread[] myThreads = new MyThread[100];
for (int i=0;i<100;i++){
myThreads[i] = new MyThread();
}
for (int i=0;i<100;i++){
myThreads[i].start();
}
}
}
打印結果:
....
9996
9997
9998
9999
10000
成功達到累加的效果。
3.5 原子類也不安全
原子類在具有有邏輯性的情況下輸出結果也具有隨機性。
示例代碼:
public class MyThread extends Thread {
private static AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
addCount();
}
private static void addCount() {
for (int i = 0;i<100;i++){
count.incrementAndGet();
}
System.out.println(count);
}
public static void main(String[] args) {
MyThread[] myThreads = new MyThread[100];
for (int i=0;i<100;i++){
myThreads[i] = new MyThread();
}
for (int i=0;i<100;i++){
myThreads[i].start();
}
}
}
打印結果:
....
7900
7200
7300
7100
可以看到,打印順序出錯了,出現這樣的原因是因為AtomicInteger的方法時原子的,但方法與方法之間的調用卻不是原子的。解決這樣的問題必須要用同步。
3.6 synchronized代碼塊有volatile同步的功能
關鍵字synchronized可以使多個線程訪問同一個資源具有同步性,而且它還具有將線程工作內存中的私有變量與公共內存中的變量同步的功能。
我們把前面講到的異步死循環代碼改造一下:
public class PrintString implements Runnable{
private boolean isRunnning = true;
@Override
public void run() {
String lock = new String();
System.out.println("Thread begin: "+Thread.currentThread().getName());
while (isRunnning == true){
synchronized (lock){
//加與不加的效果就是是否死循環
}
}
System.out.println("Thread end: "+Thread.currentThread().getName());
}
public boolean isRunnning() {
return isRunnning;
}
public void setRunnning(boolean runnning) {
isRunnning = runnning;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(1000);
printString.setRunnning(false);
System.out.println("我要停止它!" + Thread.currentThread().getName());
}
}
打印結果:
Thread begin: Thread-A
我要停止它!main
Thread end: Thread-A
關鍵字synchronized可以保證在同一時刻,只有一個線程可以執行某一個方法或某一個代碼塊。它包含兩個特征:互斥相和可見性。同步synchronized不僅可以解決一個線程看到對象處於不一致的狀態,還可以保證進入同步方法或者同步代碼塊的每個線程,都看到由同一個鎖保護之前所有的修改效果。
學習多線程並發。要著重“外修互斥,內修可見”,這是掌握多線程、學習多線程並發的重要技術點。
4.文末總結
本文應該著重掌握如下技術點:
- synchronized對象監視器為Object時的使用。
- synchronized對象監視器為Class時的使用。
- 非線程安全是如何出現的。
- 關鍵字volatile的主要作用。
- 關鍵字volatile與synchronized的區別及使用情況。
5.參考
《Java多線程編程核心技術》高洪巖著
文中若有筆誤歡迎評論區指正!
Java多線程編程核心技術(二)對象及變量的並發訪問