作者:~小明学编程
文章专栏:JavaEE
格言:热爱编程的,终将被编程所厚爱。
目录
多线程所带来的不安全问题
什么是线程安全
线程不安全的原因
修改共享数据
修改操作不是原子的
内存可见性对线程的影响
指令重排序
解决线程不安全的问题
synchronized关键字
互斥
刷新内存
可重入
synchronized 的几种用法
直接修饰普通方法:
修饰静态方法
修饰代码块
锁类对象
volatile
Java 标准库中的线程安全类
死锁
什么是死锁
死锁的情况
死锁的必要条件
wait 和 notify
wait()
notify()
notifyAll()方法
wait()和sleep()的对比
多线程所带来的不安全问题
我们来看一下下面的这一段代码,代码的内容主要就是,一个变量count,我们用两个线程同时对其进行操作,每个线程都让其自增50000,但是我们最终看到的结果确是count不到100000,在50000和100000之间。
class MyClass{
public static int count;
public void increase() {
count++;
}
}
public class Demo2 {
private static int count1;
public static void main(String[] args) throws InterruptedException {
MyClass myClass = new MyClass();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count1++;
myClass.increase();
}
}
});
Thread thread1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count1++;
myClass.increase();
}
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println(count1);//65584
System.out.println(MyClass.count);//65478
}
}
这是什么原因呢?
什么是线程安全
所谓的线程安全就是:我们在多线程代码之下的运行结果是符合我们预期的并且和单线程下的运行结果一致,我们就说这是线程安全的。
上面的代码肯定不符合我们的预期也不是线程安全的。
线程不安全的原因
总体回答这个问题的话就是:
1.线程是抢占式执行的,线程之间的调度充满着随机性。
2.多个线程对同一个变量进行修改操作。
3.针对变量的操作不是原子性的。
4.内存的可见性也会影响线程的安全。
5.代码的顺序性。
修改共享数据
我们上面的代码就是属于修改共享的数据,其中我们的count是在堆上因此可以被多个线程共享。
修改操作不是原子的
所谓的原子性就是不可再分割的意思,例如我们上面的++操作其实是由三部分组成的,首先是要把数据从内存读到cpu上,然后++,最后再写回去,如果在这中间我们一个线程读到数据了,然后另外的一个线程也读到数据了,这时候两个线程++完毕返回的是同样的值,这也是我们上面产生问题的原因。
内存可见性对线程的影响
因为我们是多线程的操作所以共享同一块资源,当我们在对同一块资源下执行时候就能看到彼此。
我们的线程想要获取到内存里面的东西的话,都是先从内存中去拿然后放到寄存器里面去,然后再我们线程再去从寄存器里面去拿,当我们想要修改数据的时候就先放到寄存器再去放回内存中,这就导致了一个问题,如果我们改完了一个数据放到了寄存器还没放回内存的时候,这个时候我们另外线程从内存中拿数据就拿不到最新的数据了。
这就是内存可见性对线程的影响。
指令重排序
指令的重排序是我们编译器对我们代码的执行顺序进行的调整,同样的目的但是顺序不一样我们所消耗的资源可能也不一样,我们编译器一般会保证我们执行的高效会对代码的顺序进行调整,但是当多线程的时候就不安全了,指令的重排序可能会使我们的线程发生混乱。
解决线程不安全的问题
synchronized关键字
互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待,这就解决了我们刚才不同的线程操作同一个变量的问题了,当我们一个线程去操作那个count的时候其它的线程加了锁此时别的线程就不能再去操作那个count了。
刷新内存
我们的刷新内存就是为了解决我们的共享内存的问题,我们前面说到我们拷贝内存到我们的寄存器里面再到我们的线程中,我们修改数据再原路返回,在这中间可能会有其它的线程再读这块内存,这就可能导致我们读到的数据不是最新的数据,然而加上我们的synchronized之后
1.我们首先会加锁,加锁之后别的线程就不能再去访问和读取这块内存了。
2.从内存中读取数据到寄存器和高速缓存中。
3.处理数据。
4.再将寄存器和高速缓存中的数据返回到内存中。
5.开锁,其它的线程可以读取内存中的数据了。
可重入
可重入是我们 synchronized 可以让我们的程序避免产生自己将自己锁住的关键。
所谓的自己将自己给锁住就是我们想要给同一块的代码重复的上锁,而且必须重复上锁才能继续的运行下去,如果我们不能重复上锁的话,我们就要等待该锁解除才能继续的上锁,但是要想解除该所就必须得执行重复上锁的代码,这就矛盾了,也就产生了死锁(下面详细介绍)。
static class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
synchronized void increase2() {
increase();
}
}
例如上面这段代码,我们调用increase2()的时候会对当前的对象加锁,然后我们再去调用increase()就又对当前的对象加了一次锁,这里不会产生错误是因为我们支持重复加锁,
在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息:
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取
到锁, 并让计数器自增.解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)。
synchronized 的几种用法
直接修饰普通方法:
锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
修饰静态方法
锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
修饰代码块
明确指定锁哪个对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
锁类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
volatile
volatile可以保证我们的数据是从内存中读取的,防止优化而导致的线程不安全的问题。
我们的线程在操作内存的时候会先把内存里的数据放到寄存器中然后再从寄存器中拿到数据,但是从内存中拿数据是一个很慢的操作,所以有些时候进行一些优化然后就会直接从寄存器中拿数据,这个时候如果其它的线程更改了数据,这个时候我们拿到的就是旧的了。
我们的volatile就可以保证我们的内存可见性,保证我们拿到的数据都是从内存中拿到的,而不是工作内存(寄存器,缓存)中偷懒拿到。
当然我们的synchronized()也能保证我们内存的可见性,但是我们不能无脑的频繁使用synchronized(),因为其使用多了可能会造成线程阻塞等问题大大降低了我们的性能,解决内存可见性的问题的时候使用synchronized()所要付出的代码往往更高。
Java 标准库中的线程安全类
我们Java标准库中有很多的线程不安全的类常见的有
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
因为这些类里面的代码都没有加锁,所以我们在使用的时候要格外的注意,为了解决部分的问题我们也提供了一些线程安全的类。
Vector
HashTable
ConcurrentHashMap
StringBuffer
这些类里面的关键方法都加了锁,所以在进行多线程的时候不用担心线程安全的问题。
其中我们的String类也是线程安全的虽然没有加锁但是,其本身的特性不可变让其具有线程安全。
死锁
什么是死锁
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
在我们的Java多线程操作的时候各个线程去争夺同一资源也会陷入到僵局这个时候也会产生死锁。
比如说我们前面的synchronized这个方法可以重复加锁,如果不能重复加锁的话,那么我们就会产生死锁,也就是上面说的不可重入性而导致的死锁。
死锁的情况
1.一个线程一把锁上面自己锁自己的情况。
2.两个线程两把锁,我们两个线程对两个对象分别上了锁,然后刚好这两个线程又要去操作两个对象,因为都对彼此上锁了,都到等对方结束,但是不执行又不能结束这就有产生了死锁。
2.n个线程m把锁。
死锁的必要条件
1.互斥使用:一个锁被一个线程占用以后,其它的线程就用不了了。
2.不可抢占:一个锁被一个线程占用以后,其它的线程不能抢占。
3.请求和保持:当一个线程占据多把锁的时候,除非显示的释放锁否则,否则这些锁始终都被占用。
4.环路等待,各个线程之间互相等待彼此解锁。
wait 和 notify
wait() wait(long timeout): 让当前线程进入等待状态。
notify() notifyAll(): 唤醒在当前对象上等待的线程。
注意:
wait, notify, notifyAll 都是 Object 类的方法。
wait()
我们的wait()方法主要是为了让我们当前所在的线程进入到一个等待的状态,其工作原理分为三个步骤:
1.让我们当前所在的线程进入到一个等待的状态。
2.释放当前线程所在的锁。(所以我们在用wait()方法之前一定要有锁才行)。
3.等待条件被唤醒。
结束等待条件:
1.其他线程调用该对象的 notify 方法.
2.wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
3.其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待前");
object.wait();
System.out.println("等待后");
}
}
执行代码我们会发现我们一直处于等待的状态。
notify()
notify()方法是用来通知在等待被wait()等待的线程,这个线程已经失去了锁,我们别的线程通知wait()所在的线程后继续执行当前的代码,执行完毕之后退出当前的线程,然后wait所在的线程重新获得锁接着执行后面的代码。当我们有多个线程都在等待一个对象的锁的时候我们notify()会随机的释放一个线程。
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread thread = new Thread(()->{
System.out.println("thread等待前");
synchronized (object) {
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("thread等待后");
});
thread.start();
Thread.sleep(3000);//主线程休眠
Thread thread1 = new Thread(()->{
System.out.println("thread1通知前");
synchronized (object) {
object.notify();
}
System.out.println("thread1通知后");
});
thread1.start();
}
notifyAll()方法
相比于notify()方法,notifyAll()的方法在对多个线程同时等待的情况下会将会唤醒所有等待的线程,但是这个线程回去竞争当前的锁,竞争到然后去执行自己剩下的代码。
wait()和sleep()的对比
1.我们的sleep()是休眠我们当前的线程,而wait()是用于线程通信的。
2.wait()要搭配synchronized 使用. sleep ()不需要。
3.wait()是Object的方法而sleep()是Thread 的静态方法。