JUC并发编程之共享问题学习

目录

      • 临界区
      • synchronized解决
      • 局部变量是否线程安全
      • 线程安全分析
      • Monitor
        • Java对象头
        • Monitor概念
        • Monitor工作原理
      • 轻量级锁
        • 加锁过程
        • 解锁过程
      • 锁膨胀
      • 自旋优化
        • 自旋成功
        • 自旋失败
      • 偏向锁
        • 对比轻量级锁
        • 撤销偏向状态
        • 批量重偏向
      • wait
        • sleep与wait的区别
      • 同步模式之保护性暂停
        • join原理
      • 异步模式之生产者/消费者
      • park&&unpark
        • park原理
      • 线程状态转换再次学习
      • 多把锁
        • 死锁
        • 活锁
        • 饥饿
      • ReentrantLock
        • 可重入
        • 可打断
        • 锁超时
        • 公平锁

临界区

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

一个程序运行多个线程本身是没有问题的,但是在多个线程对共享资源读写操作时发生指令交错,就会出现问题
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
为了避免临界区的竞态条件发生可以使用synchronized

synchronized解决

通过对象锁保证了临界区代码的原子性。
当存在两个线程用一份资源,其中一个线程拥有锁,在这个线程时间片结束发生上下文切换,其他线程尝试获取锁会被阻塞。当拥有锁的线程获取cpu使用时间就可以使用cpu,直到其putstatic数据将修改后的值存入静态变量并且释放锁,另一个线程才会拥有锁。

public class Test17 {
    static int counter = 0;
    static final Object room = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter++;
                    System.out.println("----"+counter);
                }

            }

        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter--;
                    System.out.println("-------------------->"+counter);
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }
}

问题1:假如是这样synchronized放在for循环外面,结果如何

synchronized (room) {
            for (int i = 0; i < 5000; i++) {
                    counter++;
                    System.out.println("----"+counter);
                }
            }

结果是count会不间断的加到5000,因为synchronize保护原子性的区域变大,保护里面所有代码的原子性。
问题2:两个线程锁不同的对象synchronized(obj1)synchronized(obj2)
会导致synchronized不起作用,因为不是对一个对象进行操作。
问题3:如果线程1synchronized(obj)但是线程2没有会怎么样?
不能保证原子性,因为线程2没有了获取锁这个步骤,就不会被锁住。

  • synchronized只能锁对象,当加在方法上面实际上锁的是this对象。
  • synchronized加在静态方法上面锁住的是类对象(比如类是Room)锁的是(Room.class)。
  • 不加synchronized的方法没有办法保证原子性

局部变量是否线程安全

局部变量是线程安全的,每个线程调用方法中的局部变量时会在线程的栈帧内存中被创建多份,因此不会共享。
但局部变量引用的对象则不一定是安全的,因为多个线程可能访问的是同一个堆里面的值。

线程安全分析

练习1. 下面的线程安全吗?

public class MyServlet extends HttpServlet {
   private UserService userService = new UserServiceImpl();
 
   public void doGet(HttpServletRequest request, HttpServletResponse response) {
   userService.update(...);
   }
}
public class UserServiceImpl implements UserService {
   private int count = 0;
 
   public void update() {
 // ...
   count++;
   }
}

答案是不安全的,MyServlet只有一份,userService是MyServlet的成员变量也只有一份,类UserServiceImpl的成员变量count也只有一份。当多个线程共享使用count资源时会不安全。
补充知识:堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),堆中的数据是线程共享的。
JUC并发编程之共享问题学习_第1张图片

练习2. 下面的线程安全吗?
下面方法就是想监测方法话费的时间

@Aspect
@Component
public class MyAspect {
 // 是否安全?
 private long start = 0L;
 
 @Before("execution(* *(..))")
 public void before() {
 //记录开始时间
 start = System.nanoTime(); 
 }
 
 @After("execution(* *(..))")
 public void after() {
 //记录结束时间
 long end = System.nanoTime();
 System.out.println("cost time:" + (end-start));
 }
}

答案是不安全的,spring中对象没加额外说明的都是单例的,单例里面的start就会被并发修改,既然被共享就会造成不安全。
练习3

public class MyServlet extends HttpServlet {
 // 是否安全
 	private UserService userService = new UserServiceImpl();
 
 	public void doGet(HttpServletRequest request, HttpServletResponse response) {
 	userService.update(...);
 }
}
public class UserServiceImpl implements UserService {
 // 是否安全
 	private UserDao userDao = new UserDaoImpl();
 
 	public void update() {
 	userDao.update();
 }
}
public class UserDaoImpl implements UserDao { 
 	public void update() {
 	String sql = "update user set password = ? where username = ?";
 // 是否安全
 	try (Connection conn = DriverManager.getConnection("","","")){
 // ...
 	} catch (Exception e) {
 // ...
 }
 }
}

答案是安全的,userDao类中没有可更改的属性。
练习4

public class MyServlet extends HttpServlet {
 // 是否安全
 	private UserService userService = new UserServiceImpl();
 
 	public void doGet(HttpServletRequest request, HttpServletResponse response) {
 	userService.update(...);
 }
}
public class UserServiceImpl implements UserService { 
 	public void update() {
 	UserDao userDao = new UserDaoImpl();
 	userDao.update();
 }
}
public class UserDaoImpl implements UserDao {
 // 是否安全
 	private Connection = null;
 	public void update() throws SQLException {
 	String sql = "update user set password = ? where username = ?";
 	conn = DriverManager.getConnection("","","");
 // ...
 	conn.close();
 }
}

答案是安全的,尽管存在成员变量Connection但是由于UserDao userDao = new UserDaoImpl();这里是新建了对象,对象之间的成员变量不是共享的。
练习5

public abstract class Test {
 
 public void bar() {
 // 是否安全
 	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 	foo(sdf);
 }
 
 public abstract foo(SimpleDateFormat sdf);
 
 
 public static void main(String[] args) {
 	new Test().bar();
 }
}

答案是不安全的,因为局部变量sdf被传递给一个抽象方法,其子类会做一些不恰当的事情导致不安全。
foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法
那么就可以顺便解释一下为什么String要设成final类型?保证没有子类破坏String中某一个方法的行为。

Monitor

Java对象头

Java对象在内存中由两部分组成,对象头和对象的成员变量组成。
Java对象头
普通对象:在32位虚拟机中是8个字节64bits,由32位的Mark Word和32位的
Mark Word组成
JUC并发编程之共享问题学习_第2张图片
Klass Word组成
Klass Word是一个指针指向对象所从属的class,简单来说可以帮助找到类对象。

Monitor概念

Monitor被翻译为监视器
每个对象都可以关联Monitor,关联后做什么呢?如果synchronized给对象上锁后,对象头Mark Word会指向Monitor对象的指针。关联成功后Mark Word的标志位2bits会变为10,同时前面的30bits会变为指向Monitor的指针。

Monitor工作原理

JUC并发编程之共享问题学习_第3张图片
当synchronized对象后,对象头的Mark Word指向Monitor,但线程1使用时,若此时没有其他线程使用,Monitor会设置拥有者为线程1,。当线程2和线程3想要使用时,会查看Monitor的拥有者,发现是线程1就会在Monitor的EntryList中等待同时进入了阻塞状态。补充:(EntryList)的底层是链表实现。
当线程1执行完synchronized代码后,Monitor就会重设拥有者,通知EntryList中的线程可以执行了,并设置Monitor的拥有者。

轻量级锁

使用场景:如果多个线程访问的时间不同就可以用轻量级锁进行优化。

加锁过程

线程在执行同步代码块之前,会在当前线程的栈帧中创造锁记录,锁记录中的Object reference指向锁对象,并尝试用cas替换掉对象头中的Mark Word,如果cas替换成功,对象头含有锁记录地址和状态00,Mark Word存入锁记录。

解锁过程

先判断锁记录,如果有为null的记录表示有重入,重置锁记录(表示重入计数减一),若不为null用cas将Mark Word的值恢复给对象头。

锁膨胀

当线程2对对象进行轻量级加锁时,线程1已经对该对象加轻量级锁。
线程2加锁失败进入锁膨胀

  1. 为Object申请Monitor锁,让Object指向重量级锁地址。
  2. 然后线程2进入Monitor锁的等待队列EntryList中.

此时解锁就会发生变化,用cas恢复Mark Word给对象头会失败,进入解锁重量级锁的流程。

  1. 根据对象头的Mark Word找到Monitor的地址
  2. 设置Monitor的onwer为null
  3. 把EntryList的线程2唤醒

自旋优化

自旋成功

简单来说,线程2访问同步代码块,获取Monitor,结果发现onwer是线程1。此时不会立马进入EntryList变为阻塞状态,而是会进行自旋重试几次,当线程1解锁后,线程2就会成功加锁。

自旋失败

进行几次自旋重试没有成功后,进入阻塞状态。

偏向锁

只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。只要以后不发生竞争,这个对象就归该线程所有。

对比轻量级锁

轻量级锁在锁重入时仍需要cas操作,而偏向锁就不需要。下面结合以下代码分析不同锁的流程

static final Object obj = new Object( ) ;public static void method1() {
        synchronized( obj ) {
            //同步块A
            method2( ) ;
        }
    }
    public static void method2( ) {
        synchronized( obj ) {
            //同步块B
            method3( ) ;
        }
    }
    public static void method3( ) {
        synchronized( obj ) {
            //同步块C
        }
    }

轻量级锁

生成锁记录
生成锁记录
生成锁记录
method1方法
通过cas用锁记录替换MarkWord
method2方法
method3方法

偏向锁流程

method1方法
通过ID替换MarkWord
method2方法
检查ID是否是自己
method3方法

撤销偏向状态

当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(此时不会执行任何代码)撤销偏向锁。

批量重偏向

简单来说就是让对象的ThreadID会变化。

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程1的对象仍有机会重新偏向线程2,重偏向会重置对象的ThreadID。

当撤销达到20次后,JVM就会认为偏向存在问题,再给对象加锁时重新偏向至加锁线程。前面的19次只是将偏向锁变为轻量级锁,而一旦达到20次,将启动偏向锁并设置偏向锁的偏向对象。

批量撤销
当撤销偏向锁阈值超过40次后,JVM 会觉得自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

wait

wait可以让暂时不满足条件的进入WaitSet中,让可以进行的线程去使用cpu,当线程2的条件满足时,就会从WAITING状态变为BLOCKED状态。
JUC并发编程之共享问题学习_第4张图片
wait()方法,让进入object的线程到waitSet等待。代码如下

public class Test1 {
    static final Object lock = new Object();
    public static void main(String[] args) {

        synchronized (lock) {
            try {
                lock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

notify()方法 唤醒Object上一个线程
obj.notifyAll() 唤醒Object上所有等待线程

wait方法深入学习,进入lock.wait()的方法里面可以看到是用的如下方法:
代表的是无限制的等待下去。

public final void wait() throws InterruptedException {
        wait(0);
    }

源码里面还有一个带参数,表示等待一定的时间,如果在这个时间没有唤醒,就结束等待继续向下执行。

public final native void wait(long timeout) throws InterruptedException;

这是带两个参数的,第二个参数代表的是纳秒,不过仔细阅读之后你会发现比较有趣的事情,if (nanos > 0) {timeout++;},还是在毫秒上加1。

public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos > 0) {
            timeout++;
        }

        wait(timeout);
    }

sleep与wait的区别

  1. sleep 是 Thread 方法,而 wait 是 Object 的方法
public static native void sleep(long millis) throws InterruptedException;
public final void wait() throws InterruptedException {
        wait(0);
}
  1. sleep 不需要强制和 synchronized 配合使用,但 wait 需要
    和 synchronized 一起用
  2. sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁 ,它们状态都是TIMED_WAITING

同步模式之保护性暂停

保护性暂停主要用在一个线程等待另一个线程的结果。有一个结果需要从一个线程传递到另一个线程,让他们关联同一个GuardedObject,join的实现、Future的实现,采用的就是此模式,此模式可以通过唤醒方法和wait方法来实现。

等待线程2的结果
任务完成并赋值
线程1
GuardedObject
线程2

保护暂停模式需要一个中间类GuardedObject,来确保是一个线程执行结束,并将值赋给另一个线程。这样就要求线程之间一一对应。而接下来的异步模式之生产者/消费者可以解决这一问题。

join原理

join底层就是使用的保护性暂停的模式
join的源码如下,可以看到无参的join调用的是带参数的join,我们直接看带参的join

public final void join() throws InterruptedException {
        join(0);
    }

带参的join

public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

可以看到先检查了参数,当参数为0时就会一直等待线程结束。下面代码的isAlive()判断代码是否一直存活。

if (millis == 0) {
    while (isAlive()) {
       wait(0);
   }
}

当参数大于0,会用到开始时间与经历时间

now = System.currentTimeMillis() - base;经历时间
long delay = millis - now最大超时时间减去经历时间得到这一轮等待时间
delay需要等待时间小于0说明不需要等待了。

异步模式之生产者/消费者

和保护性暂停中的GuardObject 不同,生产者/消费者模式不需要产生结果和消费结果的线程一一对应消费队列可以用来平衡生产和消费的线程资源,生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据。

put
put
take
生产者1
消息队列t1,t2生产的
生产者2
消费者

park&&unpark

park()暂停当前线程
unpark()恢复某个线程的运行
使用这两个方法最大的特点就是unpark()park()之前使用依然有效。
与wait()和notify()的区别
park & unpark 是以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不能精确唤醒我们锁想要唤醒的线程。wait,notify 要配合 Object Monitor使用,而park不需要。

park原理

当调用park方法时,检查_counter为0,获得_mutex互斥锁,线程进入_cond条件变量阻塞并设置_counter为0。
当调用unpark方法时,设置_counter为1,唤醒阻塞的线程,线程恢复运行,并置_counter为0。

线程状态转换再次学习

还是这个经典的图,正所谓“少年读书,如隙中窥月;中年读书如庭中望月”,当你再次回首又能深入理解一下。
JUC并发编程之共享问题学习_第5张图片

NEW --> RUNNABLE
线程调用t1.start() 方法时,由 NEW --> RUNNABLE
RUNNABLE <–> WAITING
t1 线程用 synchronized(obj) 获取锁对象

  • 调用了obj.wait()方法后进入WAITING
  • 调用obj.notify() , obj.notifyAll() , t.interrupt() 时进入BLOCKED状态
    竞争锁成功,t1 线程从 WAITING --> RUNNABLE
    竞争锁失败,t1 线程从 WAITING --> BLOCKED
  • 当前线程调用了join方法,当前线程进入WAITING
  • 当前线程调用park()方法会让当前线程从RUNNABLE --> WAITING
    调用了unpark方法会WAITING -->RUNNABLE

RUNNABLE <–>TIMED_WAITING
t1 线程用 synchronized(obj) 获取了对象锁

  • 调用obj.wait(long n)方法时,t1线程从RUNNABLE -->T工MED_WAITING
    等待时限超时或者调用了notify方法,若竞争锁成功进入RUNNABLE,否则进入BLOCKED

  • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
    当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE

RUNNABLE <–> TERMINATED

  • 当前线程所有代码运行完毕,进入 TERMINATED

多把锁

在一个房间中,我既想学习又想追番,有什么办法让两件事情一起执行呢?
代码如下

@Slf4j
public class Test11{

    public static void main(String[] args) throws InterruptedException {
        BigRoom bigRoom = new BigRoom();
        new Thread(() -> {
            bigRoom.study();
        },"1").start();
        new Thread(() -> {
            bigRoom.follow();
        },"2").start();
    }
}
@Slf4j(topic = "c")
class BigRoom {
    public void follow() {
        synchronized (this) {
            log.debug("追番");
            Sleeper.sleep(2);
        }
    }
    public void study() {
        synchronized (this) {
            log.debug("学习");
            Sleeper.sleep(1);
        }
    }
}

可以看到现在锁住的是BigRoom这个对象,没有办法让我们同时进行这两件事情,那我们就要进行改进。
在BigRoom里面新建两个类,这样就可以实现让两件事情一起执行。

@Slf4j(topic = "c")
class BigRoom {
    private final Object study = new Object();
    private final Object follow = new Object();
    public void follow() {
        synchronized (follow) {
            log.debug("追番");
            Sleeper.sleep(2);
        }
    }
    public void study() {
        synchronized (study) {
            log.debug("学习");
            Sleeper.sleep(1);
        }
    }
}

死锁

线程1获得A对象的锁,接下来想获取B对象的锁。线程2获得B对象的锁,接下来想获取A对象的锁,这时就会发生死锁。
代码如下

Thread t1 = new Thread(() -> {
            synchronized (A) {
                log.debug("lock A");
                sleep(1);
                synchronized (B) {
                    log.debug("lock B");
                    log.debug("...");
                }
            }
        }, "线程1");

        Thread t2 = new Thread(() -> {
            synchronized (B) {
                log.debug("lock B");
                sleep(0.5);
                synchronized (A) {
                    log.debug("lock A");
                    log.debug("...");
                }
            }
        }, "线程2");

活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束。
比如说:一个线程加到20结束,一个线程减到0结束,这样双方都无法结束。
解决:让两个线程的间隔时间随机。

饥饿

一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。

ReentrantLock

  1. 可中断
  2. 可以设置超时时间
  3. 可以设置为公平锁,防止饥饿问题
  4. 支持多个条件变量

可重入

可重入是指同一个线程如果首次获得了这把锁,因为它是这把锁的拥有者,因此有权利再次获取这把锁。
可以测试如下代码,发现可以进行多次加锁。

public class Test22 {
    private static ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            log.debug("尝试获得锁");
            try {
                lock.lock();
                log.debug("获得到锁");
                m1();
            } finally {
                lock.unlock();
            }
        }, "t1");
        t1.start();
    }
    public static void m1(){
        lock.lock();
        try {
            log.debug("方法1获得到锁");
            m2();
        } finally {
            lock.unlock();
        }
    }
    public static void m2(){
        lock.lock();
        try {
            log.debug("方法2获得到锁");
        } finally {
            lock.unlock();
        }
    }
}

可打断

调用lock.lockInterruptibly();可以使锁变为可打断状态。如果是不可中断模式lock.lock();,那么即使使用了 interrupt 也不会让等待中断

public static void main(String[] args) {
            ReentrantLock lock = new ReentrantLock();
            Thread t1 = new Thread(() -> {
            log.debug("等待锁中...");
            try {
                
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
                log.debug("被打断");
                return;
            }
            try {
                log.debug("获得了锁");
            } finally {
                lock.unlock();
            }

        }, "t1");
            lock.lock();
            log.debug("主线程获得了锁");
            t1.start();
            try {
                sleep(1);
                t1.interrupt();
                log.debug("执行打断");
            } finally {
                lock.unlock();
            }
    }

锁超时

获取锁的过程会等待一定的时间,如果对方不放开锁,就放弃等待,表示这次获取锁失败了。

公平锁

进入ReentrantLock的源码查看,有参构造方法如下,可以设置fair为true来保证公平性。可以使得先进入阻塞队列的先获得锁。

public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

总结:锁这里的原理还是比较多的,要多加理解。

你可能感兴趣的:(JUC,java)