【为了让学习变得轻松、高效,有想提升的编程能力,低成本学习更好的课程加 v:642620018 帮助大家在成为编程高手的道路上披荆斩棘。】
锁是指谁?
Object o = new Object();
Synchronized(o);
我们一般认为Synchronized锁定的是这段代码块但事实上,Synchronized锁定的是锁这个对象。不仅如此Synchronized锁定的是heap内存中的这个对象而不是这个引用。
一个例子
/**
- 锁定某对象o,如果o的属性发生改变,不影响锁的使用
- 但是如果o变成另外一个对象,则锁定的对象发生改变
- 应该避免将锁定对象的引用变成另外的对象
- @author mashibing
*/
package yxxy.c_017;
import java.util.concurrent.TimeUnit;
public class T {
Object o = new Object();
void m() {
synchronized(o) {
while(true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
T t = new T();
//启动第一个线程
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//创建第二个线程
Thread t2 = new Thread(t::m, "t2");
t.o = new Object(); //锁对象发生改变,所以t2线程得以执行,如果注释掉这句话,线程2将永远得不到执行机会
t2.start();
}
Java的锁的本质就是内存对象上的一段信息,刚开始t2线程是抢不到那一把锁的因为被t1所占。但是后来o指向了另一个全新的对象这个在堆内存中的对象还没有被当做锁使用所以t2就拿它来当做自己的锁。见下图:
拓展:
由于锁的特性所以一般我们不要使用字符串常量来作为锁对象,这样会使得线程莫名的阻塞。看起来是两个字符串的引用但是他们指向的是同一段的内存。
重入锁
在同一个线程中同步代码块可以多次获得同一把锁。这种情况叫做可重入锁。当然还有一些引用计数的规则等等。这里重点强调的是调用,一旦是调用那么也就说明他们两个是在同一个方法中的。
Synchronized(m){undefined
Synchronized(m){undefined
}
}
ReentrantLock的特性
·这是一种重入锁的实现。它有一个很大的特点就是必须的手动开启锁和释放锁。尤其是这个释放锁,不能忘记否则程序则会一直阻塞。与Synchronized不同的是Synchronized在遇到异常的时候就会释放锁但是ReentrantLock在异常下是不会释放锁的,因此经常在finally中进行锁的释放。
·locked = lock.tryLock(5, TimeUnit.SECONDS);尝试去获得锁,如果5秒还是没有获得到那么就会向下是执行。
·可以被打断的锁,如果一段代码被lock.lockInterruptibly(); 这个锁锁住,那么他能够被
t2.interrupt();这样的语句去手动打断。
·公平锁与不公平锁,通常来说Synchronized的锁是一种不公平的锁,而ReentrantLock可以实现公平锁。那什么是不公平呢?就是由于随机化的原因有的线程会由于运气不好久久得不到执行。公平就是使用了一种时间上的调度算法来使得每个线程的都能够得到公平的执行。
线程通信的底层
线程通信通常有两种,一种是读取共享的一段内存,还有一种就是线程之间互相通信。Java的线程通讯采用的是读取共享的一段内存。
Volatile关键字
这是一个案例,在上述代码不开volatile的时候new出来的那个线程把running从主内存中读取出来读到自己的缓冲区中并且以此为标签来执行while()中的代码块。而且一直执行下去在繁忙的情况下不会去读取主内存中的值,即使main线程对这个值做了修改。所以我们会看到while()中的代码一直被执行。
当volatile开启的时候,这两个线程之间也就变成了可见的了。具体原因就是在主内存的running的值被修改之后这时有一个线程会通知new出来的线程但缓冲区说你的running的值已经过期了,所以缓冲区的running的值会变成false随之while的执行结束。
值得一提的是在线程空闲的时候有可能会去主内存中读取值。
拓展:Volatile与Synchronized的联系与区别
Volatile只保证可见性,就是线程之间的变量是互相可见的,但是不能够保证原子性。
Synchronized,同时保证了原子性和可见性,因为Synchronized会将程序串行执行,当上一个程序执行完毕之后会将值写入到主内存中下一个线程来到时肯定会读取到这段内容。以下代码展示的是Synchronized的可见性
package test;
import java.util.concurrent.TimeUnit;
public class VolatileTest {undefined
volatile boolean b = true;
/synchronized/ void m () {undefined
System.out.println(b);
try {undefined
Thread.sleep(10);
} catch (InterruptedException e) {undefined
e.printStackTrace();
}
b = !b;
}
public static void main(String[] args) {undefined
VolatileTest vt = new VolatileTest();
new Thread(vt :: m,"t1") .start();
new Thread(vt :: m,"t1") .start();
try {undefined
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {undefined
e.printStackTrace();
}
}
}
这种感觉类似于数据库中的事务隔离级别。
重要拓展JMM(Java内存模型)
多线程的出现会使得CPU的利用率大大提高从而进一步压榨CPU的性能。
由于计算机CPU运算速度与处理器的运算速度有几个数量级的差距所以现代计算机系统中都会在CPU与内存之间加入一层高速缓存来作为处理器与内存之间的缓冲。在这样的情况下想执行一段逻辑我们就先得将内存中的数据装载到高速缓存中然后运算能够快速的进行当运算结束后再将数据同步到内存中。当然高速缓在解决了CPU与内存速度不匹配的问题后又引出了新的问题。就是缓存一致性。由于每个线程都会在执行运算的时候都会先将主存中的数据同步到自己的高速缓存中进行运算修改而且他们的高速缓存互不可见所以有可能出现对于主存中的同一个值不同的高速缓存中会有不同的呈现这也就造成了缓存一致性问题。为了避免这个问题需要我们在读写运算操作的时候遵守一些协议,而内存模型可以理解为在特定的协议下对特定内存和高速缓存访问的抽象过程。
这段描述与Java内存模型的原理几乎一致,Java内存模型想要达到的目的就是屏蔽操作系统与硬件的差异,让Java程序在不同的平台上都能够达到一致的效果。在JMM中主要包括两部分主内存和工作内存。
主内存(对应于一般所说的堆内存):可以类比成我们常提到的物理内存,它里面包含着一些共享的数据,比如说实例字段,静态字段,构成数组的对象元素但不包括线程私有的变量如局部变量与方法参数。
工作内存(对应于一般所说的虚拟机栈),保存了该线程使用到的主内存的变量的拷贝副本。对于线程变量的所有操作只有在工作内存中才可以。而线程变量值的传递都需要用到主内存。
他们的对应关系如下图所示:
AtomicXXX类型
因为操作(当然也包含其他的系列操作)不是一个原子操作,所以在需要保证原子的时可以通过上锁来解决问题,也可以通过以下的案例来解决:
/**
- 解决同样的问题的更高效的方法,使用AtomXXX类
- AtomXXX类本身方法都是原子性的,但不能保证多个方法连续调用是原子性的
- @author mashibing
*/
package yxxy.c_015;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class T {
/volatile/ //int count = 0;
AtomicInteger count = new AtomicInteger(0);
/*synchronized*/ void m() {
for (int i = 0; i < 10000; i++)
//if count.get() < 1000
count.incrementAndGet(); //count++
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(t::m, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
一道淘宝面试题的演化(Volatile与门闩机制)
实现一个容器,提供两个方法,add,size。写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。
方案一:
正常的思维,单纯的判断容器的大小是无效的,因为线程之间是不可见的
public class MyContainer1 {
List lists = new ArrayList();
public void add(Object o) {
lists.add(o);
}
public int size() {
return lists.size();
}
public static void main(String[] args) {
MyContainer1 c = new MyContainer1();
new Thread(() -> {
for(int i=0; i<10; i++) {
c.add(new Object());
System.out.println("add " + i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
new Thread(() -> {
while(true) {
if(c.size() == 5) {
break;
}
}
System.out.println("t2 结束");
}, "t2").start();
}
}
方案二:
在方案一的基础上加上volatile关键字,成功运行。因为两个线程之间是彼此可见的。
volatile List lists = new ArrayList();
方案三:由于方案二的while的循环一直在监视所以十分的浪费CPU。因此我们将机制改为了wait()与notify()。也就是当t1检测到到达size到达5的时候叫醒正在沉睡的t2。但是t2、t1使用的是通一把锁而notify不会释放锁(当然它与notify起作用的前提是获得锁)。所以我们的最终方案是当t1叫醒t2的同时自己要wait()这样才保证了t2能够拿到锁然后t2执行完之后也要唤醒t1(由于这时候t2已经执行完毕了所以锁自然释放)。代码如下:
public class MyContainer4 {
//添加volatile,使t2能够得到通知
volatile List lists = new ArrayList();
public void add(Object o) {
lists.add(o);
}
public int size() {
return lists.size();
}
public static void main(String[] args) {
MyContainer4 c = new MyContainer4();
final Object lock = new Object();
new Thread(() -> {
synchronized(lock) {
System.out.println("t2启动");
if(c.size() != 5) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 结束");
//通知t1继续执行
lock.notify();
}
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
new Thread(() -> {
System.out.println("t1启动");
synchronized(lock) {
for(int i=0; i<10; i++) {
c.add(new Object());
System.out.println("add " + i);
if(c.size() == 5) {
lock.notify();
//释放锁,让t2得以执行
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "t1").start();
}
}
终极方案:
使用门闩机制
使用Latch(门闩)替代wait notify来进行通知
好处是通信方式简单,同时也可以指定等待时间
使用await和countdown方法替代wait和notify
CountDownLatch不涉及锁定,当count的值为零时当前线程继续运行
当不涉及同步,只是涉及线程通信的时候,用synchronized + wait/notify就显得太重了
这时应该考虑countdownlatch/cyclicbarrier/semaphore
@author mashibing
首先new一个门闩CountDownLatch latch = new CountDownLatch(1);里面有一个参数,同时他有一个方法就是latch.await();,它会插在代码之间阻拦代码的执行。同时latch.countDown();方法会减少门闩的数量当门闩的数量减少为0的时候这时门会自动打开这时候latch.await();会向下执行。整个过程不涉及锁的机制,高效得实现了线程之间的通信。
public class MyContainer5 {
// 添加volatile,使t2能够得到通知
volatile List lists = new ArrayList();
public void add(Object o) {
lists.add(o);
}
public int size() {
return lists.size();
}
public static void main(String[] args) {
MyContainer5 c = new MyContainer5();
CountDownLatch latch = new CountDownLatch(1);
new Thread(() -> {
System.out.println("t2启动");
if (c.size() != 5) {
try {
latch.await();
//也可以指定等待时间
//latch.await(5000, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 结束");
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
new Thread(() -> {
System.out.println("t1启动");
for (int i = 0; i < 10; i++) {
c.add(new Object());
System.out.println("add " + i);
if (c.size() == 5) {
// 打开门闩,让t2得以执行
latch.countDown();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
}
}
最后感谢马老师,一个专心做教育的老师。