java基础

  • 1. 集合
    • 1.1 概述
    • 1.2 Collection集合
      • 常用方法:
      • 迭代器
      • List集合
      • ArrayList集合
      • LinkedList
      • Vector集合
      • 泛型
      • HashSet集合
      • TreeSet集合
    • 1.3 Map集合
      • HashMap
      • Hashtable
      • Properties
      • TreeMap
      • Collections集合工具类
    • 1.4 面试题
  • 2. IO流
    • 2.1 概述
    • 2.2 流
      • 文件专属
      • 转换流
      • 缓冲流
      • 数据流
      • 标准输出流
      • 对象专属流
      • 序列化流
      • IO和Properties联合使用
    • 2.3 File类
      • 拷贝目录
  • 3. 多线程
    • 3.1 概述
    • 3.2 实现线程的两种方式
    • 3.3 线程的生命周期
    • 3.4 相关方法
    • 3.5 线程调度
    • 3.6 线程安全(重点)
      • 同步代码块synchronized
      • 死锁现象
    • 3.7 守护线程和定时器
    • 3.8 实现线程的第三种方式
    • 3.9 生产者消费者模型
  • 4. 遗漏的基础
    • 4.1 static关键字
    • 4.2 多态
    • 4.3 抽象类和接口

1. 集合

1.1 概述

数组其实就是一个集合,集合实际上就是一个容器,可以来容纳其他类型的数据。
在实际开发中,假设连接数据库,数据库当中有10条记录,那么假设把这10条记录查询出来,在java程序中会将10条数据封装成10个java对象,然后将10个java对象放到某一个集合当中,将集合传到前端,然后遍历集合,将一个个数据展现出来。

集合不能直接存储基本数据类型,也不能直接存储java对象,集合当中存储的都是java对象的内存地址。集合中任何时候存储都是“引用”。

不同的集合,底层对应不同的数据结构。往不同的集合中存储元素,等于将数据放到了不同的数据结构中。

重点掌握:什么情况下选择哪一种合适的集合去使用。

集合的继承结构图:

总结:
ArrayList:底层是数组。
LinkedList:底层是双向链表
Vector:底层是数组,线程安全,效率较低,使用较少。
HashSet:底层是HashMap,放到HashSet集合中的元素等同于放到HashMap集合的key部分了。
TreeSet:底层是TreeMap,放到TreeSet集合中的元素等同于放到了TreeMap集合key部分了。
HashMap:底层是哈希表。
Hashtable:底层也是哈希表,只不过是线程安全的,效率较低,使用较少。
Properties:是线程安全的,并且key和value只能存储字符串String。
TreeMap:底层是二叉树。TreeMap集合的key可以自动按照大小顺序排序。

List集合存储元素的特点:有序可重复。(有序是指存进和取出的顺序相同,每一个元素都有下标)
Set集合存储元素的特点:无序不可重复。
SortedSet(SortedMap)集合存储元素的特点:无序不重复,按照元素大小进行排序。

1.2 Collection集合

如果没有使用泛型,Collection可以存储Object的所有子类型;使用泛型之后,只能存储某个具体的类型。

常用方法:

add、size、clear、contains、remove、isEmpty、toArray、Iterator
注意:contains方法和remove方法,底层调用了equals方法。

//contains方法是用来判断是否包含某个元素
Collection c = new ArrayList();
String s1 = new String("abc");
c.add(s1);
String x = new String("abc");
c.contains(x); //虽然没有用add方法添加x,但这里还是返回true。
//因为底层调用equals方法,比较的是内容

User u1 = new User("jack");
c.add(u1);
User u2 = new User("jack");
c.contains(u2); //如果User没有重写equals方法,返回false;重写了equals方法,返回true。

没有重写equals方法,底层会进行内存的比较。
结论:存放在一个集合中的类型,一定要重写equals方法。

String s1 = new String("hello");
c.add(s1);
String s2 = new String("hello");
c.remove(s2); //上面添加的s1,这里删除s2
sout(c.size); //返回0
  • 深入Collection集合的contains方法
    contains方法是用来判断集合中是否包含某个元素的方法。如果包含返回true,如果不包含返回false。
    它在底层是如何判断集合中是否包含某个元素的呢?
    它是调用了equals方法进行比对的,
    equals方法返回true,就表示包含这个元素。

    public static void main(String[] args) {
        Collection c = new ArrayList();
        //向集合中存储元素
        String s1 = new String("abc");
        c.add(s1);
        String s2 = new String("def");
        c.add(s2);
    
        String x = new String("abc");   //没往集合中存储x
        // 问:集合中是否包含x?
        System.out.println(c.contains(x));  //这句相当是在判断 c集合包不包含"abc"
    }
    

    contains()比较对象:
    如果对象没有重写equals方法,即使值相同也返回false,因为contains会调用equals方法,而对象没重写equals方法,则会去调用Object类的equals方法,Object类的equals方法是用 “==” 进行比较的。

    Collection c = new ArrayList();
    User u1 = new User("jack");
    c.add(u1);
    User u2 = new User("jack");
    // user对象没有重写equals之前,这个结果为false
    System.out.println(c.contains(u2));
    
  • 关于集合中remove的操作
    remove方法也调用了equals方法,下面删掉s2,集合中s1也没了。

    Collection c = new ArrayList();
    String s1 = new String("hello");
    c.add(s1);
    String s2 = new String("hello");
    c.remove(s2);
    System.out.println(c.size());  // 0
    

    迭代器要在操作完集合后获取。集合的结构只要发生改变,迭代器必须重新获取。
    所以,在用迭代器遍历集合的时候,不能调用对象的remove方法去删除集合中的元素,不然会出现异常。
    要用迭代器中的remove方法删除。

    Collection c = new ArrayList();
    c.add("abc");
    c.add("efg");
    Iterator it = c.iterator();
    while(it.hasNext()) {
    	Object o = it.next();
    	it.remove(); //删除当前指向的元素
    }
    

    使用对象的remove删除会出现异常,原因是集合中元素删除了,没有更新迭代器。
    用迭代器去删除,会自动更新迭代器,并且更新集合。

迭代器

这里的迭代方式,在所有的Collection以及子类中可以使用。
三个方法:boolean hasNext()、object next()、void remove()。
hasNext():是否还有元素可以迭代
next():让迭代器前进一位,并且拿到元素。

boolean hasNext = it.hasNext();如果返回true,表示还有元素可以迭代;返回false表示没有更多的元素可以迭代了。

Collection c = new ArrayList();
Iterator it = c.iterator(); //拿到迭代器对象
while(it.hasNext()) {
	Object obj = it.next();
	sout(obj);
}

List集合

list集合:有序可重复。是Collection接口的子接口,所以可以使用Collection的方法,它也有自己“特色”的方法。

  • 特有的常用方法
    void add(int index, Object element)、Object get(int index)
    int indexOf(Object o)、int lastIndexOf(Object o)、Object remove(int index)
    Object set(int index, Object element)
    List myList = new ArrayList();
    

ArrayList集合

底层采用的数组这个数据结构。
优点:检索效率比较高。
缺点:随机增删元素效率比较低(但在数组末尾增删,效率不受影响);数组无法存储大数据量。

ArrayList是非线程安全的,线程安全的集合效率会低一点。

ArrayList集合初始化容量是10,可以在创建的时候指定容量new ArrayList(20);

ArrayList集合用的比较多,因为往数组末尾增删元素,效率不受影响,而且我们检索/查找某个元素的操作比较多。

  • ArrayList集合扩容
    源码中说的是:新容量 = 原容量 + 原容量>>1(位运算),也就是新容量 = 原容量 + 原容量/2
    原容量的1.5倍。
    ArrayList底层是数组,数组扩容效率比较低,所以要尽可能少的扩容。建议在使用ArrayList集合的时候预估记元素的个数,给定一个初始化容量。
  • ArrayList集合的另一个构造方法
    List list1 = new ArrayList(); //底层用的数组
    List list2 = new ArrayList(100);
    Collection c = new HashSet();
    c.add(100);
    c.add(230);
    c.add(1320);
    //通过这个构造方法可以将HashSet集合转换成List集合
    List list3 = new ArrayList(c);
    
  • 将ArrayList变成线程安全
    List list = new ArrayList(); //非线程安全
    Collections.synchronizedList(list);
    list.add("dfsdfs");
    ...
    

LinkedList

LinkedList底层采用双向链表数据结构。

单向链表:
节点是单向链表中基本的单元。每个节点Node都有两个属性:存储数据;下一个节点的内存地址。

优点:由于链表上的元素在空间存储上内存地址不连续,随机增删元素效率较高(因为链表增删不涉及大量元素的位移)。在以后开发中,如果遇到随机增删集合中元素的业务比较多,建议使用LinkedList。
缺点:查询效率较低,每一次查找某个元素的时候都需要从头节点开始往下遍历。

ArrayList:把检索发挥到极致。(末尾添加元素,效率很高)
LinkedList:把随机增删发挥到极致。
加元素,往往是加在集合末尾,所以ArrayList用的比LinkedList多。

注意:LinkedList集合底层也是有下标的。
ArrayList之所以检索效率比较高,不单纯因为下标,是底层数组发挥的作用。
LinkedList照样有下标,但是检索某个元素的时候,只能从头节点开始一个一个遍历。

List list = new LinkedList(); //底层用双向链表
list.add("a");
list.add("b");
list.add("c");
for(int i = 0; i < list.size(); i++) {
	Object obj = list.get(i);
	sout(obj);
}


1)单向链表,查找的方向只能是一个方向,而双向链表可以向前或者向后查找
2)单向链表不能自我删除,需要靠辅助节点,而双向链表,则可以自我删除,所以前面我们单链表删除节点,总是找到temp,temp是待删除节点的前一个节点。

Vector集合

底层也是一个数组,初始化容量为10。
扩容:扩容之后是原来容量的2倍。
Vector中所有的方法都是线程同步的,都带有synchronized关键字,是线程安全的。效率比较低,使用较少了。

泛型

泛型这种语法机制,只在程序编译阶段起作用,只是给编译器参考的。

使用泛型的好处:
集合中存储的元素类型统一了。
从集合中取出的元素类型是泛型指定的类型,不需要进行大量的“向下转型”。

缺点:
导致集合中存储的元素缺乏多样性。

大多数业务中,集合中元素的类型还是统一的,所以这种泛型特性被大家所认可。

List<Animal> myList = new ArrayList<Animal>(); //使用泛型,只能存储指定的类型
Cat c = new Cat();
Bird b = new Bird();
myList.add(c);
myList.add(b);
Iterator<Animal> it = myList.iterator();
while(it.hasNext()){
	//这里不用再进行强制类型转换了,直接调用。
	Animal a = it.next(); //迭代器取出来就是Animal类型
	a.move();
	/*
		if(obj instanceof Animal) {
			Animal a = (Animal)obj;
			a.move();
		}
	*/
	//不过调用子类的特有方法,还是要转型的
}

在JDK1.8之后,引入了自动类型推断机制:
List<Animal> list = new ArrayList<>(); 就是后面尖括号可以省略类型。

  • 自定义泛型
    <>中的T是一个标识符,随便写。
    java源码中经常出现的是:<E><T>
    public class Test<T> {
    	public T get() {
    		return null;
    	}
    	public void dosome(T o) {
    		sout(o);
    	}
    	public static void main(String[] args) {
    		Test<String> t = new Test<>();
    		String s = t.get(); //这里只能返回String类型,因为设置了泛型
    
    		Test<String> t2 = new Test<>();
    		t2.dosome("abc");
    	}
    }
    

HashSet集合

存和取的顺序不一样;不可重复;
放到HashSet集合中的元素实际上是放到了HashMap集合的key部分了。

TreeSet集合

无序不可重复,但是存储的元素可以自动按照大小顺序排序。(称为可排序集合)
无序指的是:没有下标,存和取的顺序可能不同。


1.3 Map集合

Map集合以key和value的方式存储数据,key和value都是引用数据类型。

  • 常用方法
    V put(key, value):向Map集合中添加键值对、
    V get(Object key):通过key获取value、
    void clear():清空map集合、
    boolean isEmpty():判断集合中元素个数是否为0、
    boolean containsKey(Object key):是否包含某个key、
    boolean containsValue(Object value):判断Map中是否包含某个value、
    Set<K> keySet():获取集合所有的key 、
    V remove(Object key):通过key删除键值对、
    int size():获取集合中键值对的个数、
    Collection<V> values():获取集合中所有的value、
    Set<Map.Entry<K,V>> entrySet():将集合转换成Set集合,1=zhangsan

  • Map集合的遍历
    方式一:先拿到Map集合所有的key,根据key,获取value。

    Map<Integer,String> map = new HashMap<>();
    map.put(1,"zhangsan");
    ...
    Set<Integer> keys = map.keySet(); //拿到所有key
    Iterator<Integer> it = keys.iterator(); //拿到Set集合的迭代器
    while(it.hasNext()) {
    	Integer key = it.next(); //拿到一个key
    	String value = map.get(key); //根据key获取value
    	sout(key + "=" + value);
    }
    

    不想用迭代器,用增强for

    Set<Integer> keys = map.keySet(); //拿到所有key
    for(Integer key : keys) {
    	sout(key + "=" + map.get(key));
    }
    

    方式二:Set<Map.Entry<K,V>> entrySet(),将map集合转换成set集合

    //先将Map集合转换成Set集合
    Set<Map.Entry<Integer,String>> set = map.entrySet();
    //拿到迭代器
    Iterator<Map.Entry<Integer,String>> it2 = set.iterator();
    //通过迭代器遍历set集合中的元素,元素对象是Map.Entry<Integer,String>类型
    while(it2.hasNext()) {
    	Map.Entry<Integer,String> node = it2.next();
    	//拿到对象中的key
    	Integer key = node.getKey();
    	//拿到对象中的value
    	String value = node.getValue();
    	sout(key + "=" +value);
    }
    

    用增强for

    for(Map.Entry<Integer,String> node : set) {
    	sout(node.getKey() + "--->" + node.getValue());
    }
    

    第二种方式效率比较高,因为获取key和value都是直接从node对象找中获取的属性值。

HashMap

HashMap集合底层是 哈希表/散列表 的数据结构,非线程安全。

  • 哈希表数据结构
    哈希表是一个一维数组和单向链表的结合体。(数组中的每个元素是一个单向链表)
    数组:在查询方面效率很高,随机增删方面效率很低。
    单向链表:在随机增删方面效率较高,在查询方面效率很低。
    哈希表将以上的两种数据结构融合在一起,充分发挥它们各自的优点。

    HashMap集合底层的源代码:

    public class HashMap {
    	//HashMap底层实际上就是一个一维数组
    	Node<K,V>[] table;
    	static class Node<K,V> {
    		//哈希值,哈希值是key的hashCode()方法的执行结果。hash值通过哈希函数/算法,可以转换存储成数组的下标。
    		final int hash; //在这里可以理解成数组下标
    		final K key; //存储到Map集合中的key
    		V value; //存储到Map集合中的value
    		Node<K,V> next; //下一个节点的内存地址
    	}
    }
    

    哈希表/散列表:一维数组,这个数组中每一个元素是一个单向链表。

    重点:要知道put怎么存,get怎么取。

    为什么哈希表的随机增删,以及查询效率都很高?
    因为增删是在链表上完成的,查询也不需要全都扫描,只需要部分扫描。

    通过图可以得出:HashMap集合的key,会先后调用两个方法,一个方法是hashCode(),一个方法是equals(),那么这两个方法都需要重写。

    注意:同一个单向链表上所有节点的hash相同,因为它们的数组下标是一样的。但同一个链表上key和key的equals方法肯定返回的是false,都不相等。

    假设HashCode()方法返回值固定为某个值,那么底层哈希表会变成纯单向链表;假设HashCode()方法方法返回值都设定为不一样的值,那么底层哈希表就成一维数组了,不会在数组元素下挂链表(散列分布不均匀)。散列分布均匀,需要你重写hashCode()方法时有一定的技巧。

  • 重点:放在HashMap集合key部分的元素,以及放在HashSet集合中的元素,需要同时重写hashCode和equals方法。
    向Map集合中存,以及从Map集合中取,都是先调用key的hashCode方法,再可能调用equals方法(equals可能调用也可能不调用)。数组下标位置上如果是null,equals不需要执行。
    一个类如果equals方法重写了,那么hashCode()方法必须重写。

  • HashMap集合的默认初始化容量是16,默认加载因子是0.75
    默认加载因子:集合底层数组的容量达到75%的时候,数组开始扩容。
    重点
    记住,HashMap集合初始化容量必须是2的倍数,这也是官方推荐的。这是为了达到散列均匀,为了提高HashMap集合的存取效率所必须的。

  • 最终结论
    放在HashMap集合中的key元素,以及放在HashSet集合中的元素,需要同时重写hashCode方法和equals方法。

  • JDK8之后,如果哈希表单向链表中元素 超过8个,单向链表这种数据结构会变成红黑树数据结构。当红黑树上的节点数量小于6时,会重新把红黑树变成单向链表数据结构。

Hashtable

线程安全。底层也是哈希表数据结构。
初始化容量是11,默认加载因子是:0.75f,扩容是:原容量*2 + 1

  • HashTable与HashMap比较
    HashMap的key和value可以为null
    Map map = new HashMap();
    map.put(null,null);
    
    而Hashtable的key和value不能为null

Properties

Properties是一个Map集合,继承Hashtable,Properties的key和value都是String类型。Properties被称为属性类对象。Properties是线程安全的。

掌握两个方法,一个存,一个取。

Properties pro = new Properties();
pro.setProperty("url","jdbc:mysql://localhost:3306/ld");
pro.getProperty("url");

TreeMap

TreeMap集合底层是一个二叉树。TreeSet集合底层实际上是一个TreeMap,放到TreeSet集合中的元素,等同于放到TreeMap集合key部分了。
TreeSet集合中的元素,无序不可重复,但是可以按照元素的大小顺序自动排序(升序)。

  • TreeSet对自定义类型的排序
    自定义类型要实现Comparable接口,不然会出现异常,因为TreeSet要进行排序,如果自定义类型没有指定对象之间的比较规则,就会出错。

    Customer c1 = new Customer(32);
    Customer c2 = new Customer(20);
    Customer c3 = new Customer(30);
    Customer c4 = new Customer(25);
    TreeSet<Customer> customers = new TreeSet<>();
    customers.add(c1);
    customers.add(c2);
    customers.add(c3);
    customers.add(c4);
    for(Customer c : customers) {
    	sout(c);
    }
    
    class Customer implements Comparable<Customer> {
    	int age;
    	public Customer(int age) {
    		this.age = age;
    	}
    	//比较的规则,kparaTo(t.key)。参数k和集合中的每一个k进行比较。
    	//可以指定升序或者降序
    	@Override
    	public int comparaTo(Customer c) {
    		int age1 = this.age;
    		int age2 = c.age;
    		if(age1 == age2) {
    			return 0;
    		} else if(age1 > age2) {
    			return 1;
    		} else {
    			return -1;
    		}
    		// return this.age - c.age; 升序
    	}
    }
    
  • 比较器(第二种排序规则)

    //创建TreeSet集合的时候,需要使用这个比较器
    //TreeSet<Customer> customers = new TreeSet<>(); 这样不行,没有通过构造方法传递一个比较器进去
    TreeSet<Customer> customers = new TreeSet<>(new CusComparator());
    customers.add(new Custom(1000));
    customers.add(new Custom(48));
    customers.add(new Custom(23));
    customers.add(new Custom(90));
    //这里省去Custom类的编写。
    ...
    //单独写一个比较器。比较器实现Comparator接口。
    class CusComparator implements Comparator<Custom> {
    	@Override
    	public int compare(Custom o1, Custom o2) {
    		return o1.age - o2.age;
    	}
    }
    

    使用匿名内部类:可以不写比较器。

    TreeSet<Customer> customers = new TreeSet<>(new CusComparator<Custom>(){
    	@Override
    	public int compare(Custom o1, Custom o2) {
    		return o1.age - o2.age;
    	}
    });
    customers.add(new Custom(1000));
    ...
    

    最终结论:
    放到TreeSet或TreeMap集合key部分的元素想做到排序,两种方式实现:
    第一种:放在集合中的元素,实现Comparable接口。
    第二种:在构造TreeSet或TreeMap集合的时候给它传一个比较器对象。

    两种方式的选择:
    当比较规则不会发生改变的时候,或者说当比较规则只有1个的时候,建议实现Comparable接口;如果比较规则有多个,并且需要多个比较规则之间频繁切换,建议使用Comparator接口。

  • 自平衡二叉树
    TreeSet/TreeMap是自平衡二叉树,遵循左小右大原则存放。
    遍历二叉树有三种方式:前序遍历、中序遍历、后序遍历。
    TreeSet/TreeMap集合采用中序遍历方式。

Collections集合工具类

集合工具类,方便集合操作。

List<String> list = new ArrayList<>();
Collections.synchronizedList(list); //变成线程安全

list.add("abf");
list.add("tff");
list.add("fdsf");
Collections.sort(list); //排序

注意:对list集合中元素排序,需要保证list集合中的元素实现了Comparable接口
String类中实现了Comparable接口,可以直接排序。如果是自定义类型,要自己实现。

sort排序只能对List集合排序,不能对Set排序。如果想对Set集合中的元素排序,要将Set集合转换为List集合。

Set<String> set = new HashSet<>();
set.add("fkasd");
set.add("tret");
set.add("gdfg");
List<String> myList = new ArrayList<>(set);
Collections.sort(myList);

1.4 面试题

HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?
答:对于 key 的 hashCode 做 hash 操作,无符号右移 16 位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。

当两个对象的 hashCode 相等时会怎么样?
答:会产生哈希碰撞。若 key 值内容相同则替换旧的 value,不然连接到链表后面,链表长度超过阈值 8 就转换为红黑树存储。

什么是哈希碰撞,如何解决哈希碰撞?
答:只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞。jdk8 之前使用链表解决哈希碰撞。jdk8之后使用链表 + 红黑树解决哈希碰撞。

如果两个键的 hashCode 相同,如何存储键值对?
答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。

2. IO流

2.1 概述

通过IO可以完成硬盘文件的读和写。

IO流的分类:
1. 按照流的方向进行分类,以内存作为参照物。
往内存中去,叫做输入(Input),或者叫做读(Read)。
从内存中出来,叫做输出(Output),或者叫做写(Write)。
2. 按照读取数据方式不同进行分类
按照字节的方式读取数据,一次读取1个字节byte,等同于一次读取8个二进制位。这种流是万能的,什么类型的文件都可以读取。包括:文本文件,图片,声音文件,视频等。
按照字符的方式读取数据,一次读取一个字符,这种流是为了方便读取普通文本文件而存在的,这种流不能读取:图片,声音,视频等文件。只能读取普通文本文件,连word文件都无法读取(word不是纯文本文件)。能用记事本打开的,都是普通文本文件。

学习方法:java中的IO流都已经写好了,我们程序员不需要关心,我们最主要还是掌握,在java中已经提供了哪些流,每个流的特点是什么,每个流对象上的常用方法有哪些。
java中所有的流都是在:java.io.*;下
主要研究:怎么new流对象,调用流对象的哪个方法是读,哪个方法是写。

java IO流的四大家族
java.io.InputStream 字节输入流
java.io.OutputStream 字节输出流
java.io.Reader 字符输入流
java.io.Writer 字符输出流
它们都是抽象类。

所有流都实现了:java.io.Closeable接口,都是可关闭的,都有**close()**方法。
流毕竟是一个管道,这个是内存和硬盘之间的通道,用完之后一定要关闭,不然会消耗(占用)很多资源。所以,用完流一定要关闭。

所有的输出流都实现了:java.io.Flushable接口,都是可刷新的,都有**flush()**方法。
养成一个好习惯,输出流在最终输出之后,一定要记得flush()刷新一下。这个刷新表示将通道/管道当中剩余未输出的数据强行输出完(清空管道)。刷新的作用就是清空管道。
注意:如果没有flush()可能会导致丢失数据。

注意:在java中只要“类名”以Stream结尾的都是字节流。以“Reader/Writer”结尾的都是字符流。

2.2 流

文件专属

  • FileInputStream
    文件字节输入流,万能的,可以采用这个流读任何类型的文件。
    以字节的方式,完成输入的操作,完成读的操作(硬盘–>内存)

    public static void main(String[] args) {
        FileInputStream fis = null;
        try {
            //fis = new FileInputStream("D:\\course\\temp"); // 转义\
            fis = new FileInputStream("D:/course/temp"); //这两种路径都可以
            //开始读
            int readData = 0;
            while((readData = fis.read()) != -1) { //读不到了,返回-1
    			sout(readData);
    		}
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
        	//在finally语句块中确保流一定关闭
            if (fis == null) { //避免空指针异常
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    

    这个程序的缺点:一次读取一个字节byte,这样内存和硬盘交互太频繁,时间/资源都耗费在交互上了。

    int read(byte[] b) 一次读取多个字节,提高执行效率。最多读取“数组.length”个字节。

    fis = new FileInputStream("demo/src/temp");
    byte[] bytes = new byte[4]; //准备一个数组,一次读4个字节
    int readCount = 0;
    //int read(byte[] b)的返回值是读到字节的数量
    while((readCount = fis.read(bytes)) != -1) {
    	//把byte数组转换成字符串,读到多少个转换多少个。
    	sout(new String(bytes, 0 , readCount));
    }
    

    为什么要用String(bytes, 0 , readCount)这个方法?
    首先要将读到的字节转换成字符串输出,但是输出不能一次将数组中的值都输出,数组中可能有上一次读取的数据没有被替换掉,所以要用索引截取。

    路径的使用:工程Project的根路径是IDEA的默认当前路径。比如上面这个路径,文件temp是放在了demo项目下的src目录下。

    常用方法
    int available():返回流当中剩余的没有读到的字节数量

    fis = new FileInputStream("temp");
    sout("总字节数量:" + fis.available());
    int readByte = fis.read(); //读了一个字节
    sout("剩下多少个字节没有读:" + fis.available()); //假设文件有6个字节,这里返回5
    

    一般用法

    byte[] bytes = new byte[fis.available()]; //这种方式不适合太大的文件,因为byte[]数组不能太大
    int readCount = fis.read(bytes);
    sout(new String(bytes)); //一次都读出来,不需要循环了
    

    long skip(long n):跳过几个字节不读

    fis.skip(3); //跳过3个字节不读
    sout(fis.read());
    
  • FileOutputStream
    文件字节输出流,负责写,从内存到硬盘。
    FileOutputStream(File file):指定路径中,文件不存在则会新建一个

    fos = new FileOutputStream("myfile");
    String s = "我是一个中国人,哈哈哈!!!";
    byte[] bs = s.getBytes(); //将字符串转换成byte数组
    fos.write(bs);
    
    fos.flush(); //写完之后,最后一定要刷新
    

    上面写,会先将原文件清空再往里面写,谨慎使用。
    FileOutputStream(String name, boolean append):在文件后面追加内容,不会清空原文件。

    fos = new FileOutputStream("chapter23/src/tempfile3", true);
    byte[] bytes = {97,98,99,100};
    fos.write(bytes);//将byte数组的全部写出,abcd
    fos.write(bytes, 0, 2); //再写出ab
    
    fos.flush(); //写完之后,最后一定要刷新
    
  • 文件复制
    使用FileInputStream + FileOutputStream 完成文件的拷贝。

    FileInputStream fis = null;
    FileOutputStream fos = null;
    try {
        //创建一个输入流对象
        fis = new FileInputStream("");
        //创建一个输出流对象
        fos = new FileOutputStream("");
        //最核心,一边读一边写
        byte[] bytes = new byte[1024*1024]; //一次最多拷贝一兆
        int readCount = 0;
        while((readCount = fis.read(bytes)) != -1) {
            fos.write(bytes, 0, readCount);
        }
        fos.flush();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
    	//两个异常分开try,不然其中一个出现异常,可能会影响到另一个流的关闭
        if (fis == null) {
            try {
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (fos != null) {
            try {
                fos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
  • FileReader
    文件字符输入流,只能读取普通文件。读取文件内容时,比较方便,快捷。

    reader = new FileReader("tempfile"); //创建文件字符输入流
    char[] chars = new char[4]; //一次读取4个字符
    int readCount = 0;
    while((readCount = reader.read(chars)) != -1) {
    	sout(new String(chars, 0, readCount));
    }
    
  • FileWriter
    文件字符输出流,写。只能输出普通文本。

    out = new FileWriter("file", true);
    char[] chars = {'我','是','中','国','人'};
    out.write(chars);
    out.write(chars, 2, 3);
    out.write("我是一名java软件工程师");
    out.write("\n");
    out.write("hello world!");
    
    out.flush();
    

转换流

将字节流转换成字符流。也可以转换文件编码。
InputStreamReader
OutputStreamWriter

缓冲流

  • BufferedReader
    带有缓冲区的字符输入流。使用这个流的时候,不需要自定义char数组,或者说不需要自定义byte数组。自带缓冲。
    FileReader reader = new FileReader("Copy02.java");
    //当一个流的构造方法中需要一个流的时候,这个被传进来的流叫做:节点流
    //外部负责包装的这个流,叫做:包装流,还有一个名字叫做:处理流
    //像当前这个程序来说,FileReader就是一个节点流。BufferedReader就是包装流/处理流
    BufferedReader br = new BufferedReader(reader);
    String s = null;
    //readLine,读一个文本行,但不带最后的换行符
    while((s = br.readLine()) != null) {
    	sout(s);
    }
    //关闭流
    //看源码得知,对于包装流来说,只需要关闭最外层流就行,里面的节点流会自动关闭
    br.close();
    
    注意:BufferedReader(Reader r) 这个构造方法,只能传字符流,不能传字节流。我们可以通过转换流转换。
    //字节流
    FileInputStream in = new FileInputStream("Copy02.java");
    //通过转换流 转换
    InputStreamReader reader = new InputStreamReader(in);
    BufferedReader br = new BufferedReader(reader);
    
  • BufferedWriter
    BufferedReader out= new BufferedWriter(new OutputStreamWriter(new FileOutputStream("Copy")));
    out.write("hello world!");
    out.write("\n");
    out.write("hello kitty!");
    out.flush();
    out.close();
    
  • BufferedInputStream
  • BufferedOutputStream

数据流

  • DataOutputStream
    数据专属的流。
    这个流可以将数据连同数据的类型一并写入文件。
    注意:这个文件不是普通的文本文档(用记事本打不开)
    //创建数据专属的字节输出流
    DataOutputStream dos = new DataOutputStream(new FileOutputStream("data"));
    byte b = 100;
    short s = 200;
    int i = 300;
    dos.writeByte(b); //把数据以及数据的类型一并写入到文件当中
    dos.writeByte(s);
    dos.writeByte(i);
    dos.close();
    
  • DataInputStream
    数据字节输入流。
    写的文件,只能使用DataInputStream去读,并且读的时候你需要提前知道写入的顺序。读的顺序需要和写的顺序一致,才可以正常取出数据。
    DataInputStream dis= new DataInputStream (new FileInputStream("data"));
    byte b = dis.readByte();
    short s = dis.readShort();
    int i = dis.readInt();
    sout(b);
    sout(s);
    sout(i);
    dos.close();
    

标准输出流

  • PrintWriter
  • PrintStream
    标准的字节输出流,默认输出到控制台。
    //联合起来写
    System.out.println("hello world!");
    //分开写
    PrintStream ps = System.out;
    ps.println("hello zhangsan");
    //标准输出流不用手动close()关闭
    
    可以改变标准字节输出流的方向。
    //标准输出流不再指向控制台,指向“log”文件
    PrintStream printStream = new PrintStream(new FileOutputStream("log"));
    //修改输出方向,将输出方向修改到“log”文件
    System.setOut(printStream);
    System.out.println("hello world);
    System.out.println("hello kitty);
    System.out.println("hello zhangsan);
    

对象专属流

ObjectInputStream
ObjectOutputStream

序列化流

序列化:Serialize,java对象存储到文件中。将java对象的状态保存下来的过程。
反序列化:DeSerialize,将硬盘上的数据重新恢复到内存当中,恢复成java对象。

参与序列化和反序列化的对象,必须实现Serializable接口,不然会出异常。

public class Student implements Serialized{
	private int no;
	private String name;
	...
}

注意:通过看源码发现,Serializable接口只是一个标志接口,这个接口当中什么代码都没有。它起到标识的作用,java虚拟机看到这个类实现了这个接口,会对这个类进行特殊待遇。
Serializable这个标志接口是给java虚拟机参考的,java虚拟机看到这个接口后,会为该类自动生成一个序列化版本号。

建议将序列化版本号手动写出来,不建议自动生成:
private static final long serialVersionUID = 1L; java虚拟机识别

序列化版本号的作用
java虚拟机识别一个类的时候,先通过类名,如果类名一致,再通过序列化版本号。不同的人编写了同一个类,但这两个类确实不是同一个类,这个时候序列化版本就起到作用了。
自动生成序列化版本号的缺陷
一个类,序列化之后,如果再修改代码,会重新编译,这个类的序列化版本号也会发生相应的改变。这时,如果再去反序列化,就会出现异常。
结论
凡是一个类实现了Serializable接口,建议给该类提供一个固定不变的序列化版本号。这样,即使这个类的代码发现了修改,但是版本号不变,java虚拟机会认为是同一个类。

序列化

Student s = new Student(1111, "zhangsan");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("students"));
//序列化对象
oos.writeObject(s);
oos.flush();
oos.close();

反序列化

Student s = new Student(1111, "zhangsan");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("students"));
//反序列化,读
Object obj = ois.readObject();
sout(obj);
ois.close();

一次序列化多个对象

List<User> userList = new ArrayList<>();
userList.add(new User(1, "zhangsan"));
userList.add(new User(2, "lisi"));
userList.add(new User(3, "wangwu"));
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("users"));
oos.writeObject(userList);

oos.flush();
oos.close();

反序列化集合

ObjectInputStream ois = new ObjectInputStream(new FileInputStream("users"));
List<User> userList = (List<User>)ois.readObject();
for(User user : userList){
	sout(user);
}
ois.close();

transient关键字,表示游离的,不参与序列化操作!

private int no;
private transient String name; //name不参与序列化

IO和Properties联合使用

以后经常改变的数据,可以单独写到一个文件中,使用程序动态读取。将来只需要修改这个文件的内容,java代码不需要改动,不需要重新编译,服务器也不需要重启,就可以拿到动态的信息。

FileReader reader = new FileReader("chapter/userinfo");
Properties pro = new Properties();
//调用Properties对象的load方法将文件中的数据加载到map集合中
pro.load(reader);
//通过key,获取value
String username = pro.getProperty("username");
String password = pro.getProperty("password");

2.3 File类

和四大家族没有关系,所以File类不能完成文件的读和写。
File对象代表文件和目录路径名的抽象表示形式,一个File对象有可能对应的是目录,有可能是文件。

我们需要掌握File类中常用的方法

  • exists():判断文件是否存在
  • createNewFile():以文件形式新建
  • mkdir():以目录的形式新建
  • mkdirs():以目录的形式新建多重目录
  • getParent():获取文件的父路径
  • getAbsolutePath():获取绝对路径
  • getName():获取文件名
  • isDirectory():判断是不是一个目录
  • isFile():判断是不是一个文件
  • lastModified():返回文件最后一次的修改时间
  • length():返回文件大小,字节数。
File f1 = new File("D:\\file");
sout(f1.exists());
if(!f1.exists()) {
	f1.createNewFile();
}
if(!f1.exists()) {
	f1.mkdir();
}
File f2 = new File("D:/a/b/c/d");
if(!f2.exists()) {
	f2.mkdirs();
}
  • listFiles():获取当前目录下所有的子文件
    File f = new File("D:\\course");
    File[] files = f.listFiles();
    for(File file : files) {
    	sout(file.getAbsolutePath());
    	sout(file.getName());
    }
    

拷贝目录

public class CopyAll {
    public static void main(String[] args) {
        // 拷贝源
        File srcFile = new File("D:\\course\\02-JavaSE\\document");
        // 拷贝目标
        File destFile = new File("C:\\a\\b\\c");
        // 调用方法拷贝
        copyDir(srcFile, destFile);
    }

    /**
     * 拷贝目录
     * @param srcFile 拷贝源
     * @param destFile 拷贝目标
     */
    private static void copyDir(File srcFile, File destFile) {
        if(srcFile.isFile()) {
            // srcFile如果是一个文件的话,递归结束。
            // 是文件的时候需要拷贝。
            // ....一边读一边写。
            FileInputStream in = null;
            FileOutputStream out = null;
            try {
                // 读这个文件
                // D:\course\02-JavaSE\document\JavaSE进阶讲义\JavaSE进阶-01-面向对象.pdf
                in = new FileInputStream(srcFile);
                // 写到这个文件中
                // C:\course\02-JavaSE\document\JavaSE进阶讲义\JavaSE进阶-01-面向对象.pdf
                String path = (destFile.getAbsolutePath().endsWith("\\") ? destFile.getAbsolutePath() : destFile.getAbsolutePath() + "\\")  + srcFile.getAbsolutePath().substring(3);
                out = new FileOutputStream(path);
                // 一边读一边写
                byte[] bytes = new byte[1024 * 1024]; // 一次复制1MB
                int readCount = 0;
                while((readCount = in.read(bytes)) != -1){
                    out.write(bytes, 0, readCount);
                }
                out.flush();
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (out != null) {
                    try {
                        out.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
            return;
        }
        // 获取源下面的子目录
        File[] files = srcFile.listFiles();
        for(File file : files){
            // 获取所有文件的(包括目录和文件)绝对路径
            //System.out.println(file.getAbsolutePath());
            if(file.isDirectory()){
                // 新建对应的目录
                //System.out.println(file.getAbsolutePath());
                //D:\course\02-JavaSE\document\JavaSE进阶讲义       源目录
                //C:\course\02-JavaSE\document\JavaSE进阶讲义       目标目录
                String srcDir = file.getAbsolutePath();
                String destDir = (destFile.getAbsolutePath().endsWith("\\") ? destFile.getAbsolutePath() : destFile.getAbsolutePath() + "\\")  + srcDir.substring(3);
                File newFile = new File(destDir);
                if(!newFile.exists()){
                    newFile.mkdirs();
                }
            }
            // 递归调用
            copyDir(file, destFile);
        }
    }
}

3. 多线程

3.1 概述

进程是一个应用程序(1个进程是一个软件),线程是一个进程中的执行场景/执行单元。一个进程可以启动多个线程。

对于java程序来说,启动java程序会先启动JVM,JVM就是一个进程。JVM再启动一个主线程调用main方法,同时再启动一个垃圾回收线程负责看护,回收垃圾。最起码,现在的java程序中有两个线程并发,一个是垃圾回收线程,一个是执行main方法的主线程。

进程和线程的关系
打个比方:进程可以看做是现实生活当中的公司,线程可以看做是公司当中的某个员工。
进程A和进程B的内存(资源)独立不共享。
线程A和线程B,堆内存和方法区内存共享,但栈内存独立。一个线程一个栈。

假设启动10个线程,会有10个栈空间,每个栈和每个栈之间,互不干扰,各自执行各自的,这就是多线程并发。

多线程并发可以提高效率。

所以,使用了多线程机制,main方法结束,可能程序也不会结束。main方法结束只是主线程结束了,主栈空了,其它的栈(线程)可能还在压栈弹栈。

分析一个问题,对于单核的CPU来说,可以做到真正的多线程并发吗?
对于4核CPU表示同一个时间点上,可以真正的有4个进程并发执行。但是单核CPU不行。真正的多线程并发,指的是每个线程之间互不影响。
而单核CPU只有一个大脑,不能做到真正的多线程并发,但是可以做到给人一种“多线程并发”的感觉。对于单核的CPU来说,在某一个时间点上实际只能处理一件事情,但是由于CPU的处理速度极快,多个线程之间频繁切换执行,给人的感觉就是多个事情同时在做。

3.2 实现线程的两种方式

  • 第一种方式
    编写一个类,直接继承java.lang.Thread,重写run方法
    创建对象,启动线程。

    public static void main(String[] args) {
    	//创建一个分支线程对象
    	MyThread myThread = new MyThread();
    	//启动线程
    	myThread.start();
    	//myThread.run();
    	for(int i = 0; i < 1000; i++) {
    		sout("主线程--->" + i);
    	}
    }
    
    class MyThread extends Thread{
    	@Override
    	public void run() {
    		for(int i = 0; i < 1000; i++) {
    			sout("分支线程--->" + i);
    		}
    	}
    }
    

    start()方法的作用是:启动一个分支线程,在JVN中开辟一个新的栈空间,只要新的栈空间开出来,start()方法就结束了,线程启动成功。
    启动成功的线程会自动调用run方法,并且run方法在分支栈的栈底部(压栈)。
    run方法在分支栈的栈底部,main方法在主栈的栈底部。run和main是平级的。

    如果不调用start方法开启线程,直接调用run方法,不会分配新的分支栈,这还是单线程。直接调用run方法,相当于还是一个普通的java类。

    直接调用run方法的内存图:
    调用start方法

  • 第二种方式

    public static void main(String[] args) {
    	//创建一个可运行的对象
    	MyRunnable r = new MyRunnale();
    	//将可运行的对象封装成一个线程对象
    	Thread t = new Thread(r);
    	// Thread t = new Thread(new MyRunnale()); 合并代码
    	//启动线程
    	t.start();
    
    	for(int i = 0; i < 100; i++) {
    		sout("主线程--->" + i);
    	}
    }
    
    class MyRunnable implements Runnable{
    	@Override
    	public void run() {
    		for(int i = 0; i < 100; i++) {
    			sout("分支线程--->" + i);
    		}
    	}
    }
    

    第二种方式实现接口比较常用,因为第一个类实现了接口,它还可以去继承其他的类,更灵活。

    匿名内部类实现:

    public static void main(String[] args) {
    	Thread t = new Thread(new Runnable() {
    		@Override
    		public void run() {
    			for(int i = 0; i < 100; i++) {
    				sout("分支线程--->" + i);
    			}
    		}
    	});
    	//启动线程
    	t.start();
    
    	for(int i = 0; i < 100; i++) {
    		sout("主线程--->" + i);
    	}
    }
    

3.3 线程的生命周期

新建状态、就绪状态、运行状态、阻塞状态、死亡状态。

3.4 相关方法

  • 获取线程的名字
    String name = 线程对象.getName();
    设置对象的名字:线程对象.setName("线程名字");
    如果不设置线程对象的名字,默认为:Thread-0、Thread-1 …

  • 获取当前对象
    Thread t = Thread.currentThread(); 返回值t就是当前线程
    currentThread方法在哪里出现,当前线程就是哪个线程。

    Thread currentThread = Thread.currentThread();
    sout(currentThread.getName());
    
  • 让当前线程进入睡眠
    static void sleep(long millis)
    静态方法:Thread.sleep(1000),参数是毫秒
    作用:让当前线程进入“阻塞状态”,放弃占有CPU时间片,让给其他线程使用。
    注意:不管谁调用的sleep方法,sleep方法在哪个线程,哪个线程睡眠,跟谁调用它没有关系。

    如果想唤醒正在睡眠的线程,可以用线程对象.interrupt()方法。
    注意:不是中断线程的执行,是终止线程的睡眠。

    public static void main(String[] args) {
    	Thread t = new Thread(new MyRunnable());
    	t.start();
    	try{
    		Thread.sleep(1000*5);
    	} catch (InterruptedException e) {
    		e.printStackTrace();
    	}
    	t.interrupt(); //干扰
    }
    
    class MyRunnable implements Runnable {
    	@Override
    	public void run() {
    		sout(Thread.currentThread().getName() + "---> begin");
    		try{
    			Thread.sleep(1000 * 60 * 60 * 24 * 365);
    		} catch(InterruptedException e) {
    			//e.printStackTrace();
    		}
    		sout(Thread.currentThread().getName() + "---> end");
    	}
    }
    

    中断t线程的睡眠,这种中断睡眠的方式是依靠了java的异常处理机制。就是执行了interrupt()方法,会抛出异常使睡眠结束。

    注意:run() 当中的异常不能throws,只能 try catch。因为run()方法在父类中没有抛出任何异常,子类不能比父类抛出更多的异常

  • 终止一个线程
    强行终止:线程对象.stop()方法。
    这种方式存在很大的缺点:容易丢失数据,因为是直接杀死了线程,可能线程没有保存的数据将会丢失。不建议使用。

    合理的终止一个线程的执行:使用标记

    public static void main(String[] args) {
    	MyRunable r = new MyRunable();
    	Thread t = new Thread(r);
    	t.setName("t");
    	t.start();
    	try {
    		Thread.sleep(5000);
    	} catch (InterruptedException e) {
    
    	}
    	//5秒后,终止这个线程
    	r.run = false; //你想什么时候终止t的执行,将标记改成false,就结束了
    }
    class MyRunable implements Runable {
    	boolean run = true;
    	@Override
    	public void run() {
    		for(int i = 0; i < 10; i++) {
    			if(run) {
    				...
    			} else {
    				//可以在return前,保存数据
    				return;
    			}
    		}
    	}
    }
    
    

3.5 线程调度

常见的线程调度

  • 抢占式调度模型
    哪个线程的优先级比较高,抢到的CPU时间片的概率就高一些/多一些。
    java采用的就是抢占式调度模型。
  • 均分式调度模型
    平均分配CPU时间片。每个线程占有的CPU时间片长度一样。
    平均分配,一切平等。
    有一些编程语言,线程调度模型采用的是这种方式。
  • 线程调度相关方法
    • 实例方法
      void setPriority(int newPriority):设置线程的优先级。
      int getPriority():获取线程优先级
      线程优先级最低是1(MIN_PRIORITY),最高是10(MAX_PRIORITY),默认是5(NORM_PRIORITY)。
      优先级比较高的获取CPU时间片可能会多一些。(大概率多一些)
      void join():合并线程
      t.join(); //t合并到当前线程中,当前线程受阻塞,t线程执行完,再执行当前线程
      
    • 静态方法
      static void yield():让位
      暂停当前正在执行的线程对象,并执行其他线程。
      yield()方法的执行会让当前线程从“运行状态”回到“就绪状态”。

3.6 线程安全(重点)

关于多线程并发环境下,数据的安全问题。
重要的是:我们要知道,我们编写的程序需要放到一个多线程的环境下运行,需要关注这些数据在多线程并发的环境下是否是安全的。

在多线程并发的环境下,满足3个条件之后,会存在线程安全问题
条件1:多线程并发。
条件2:有共享数据
条件3:共享数据有修改的行为。

注意,java中有三大变量:实例变量、静态变量、局部变量。
实例变量在堆中,
静态变量在方法区,
局部变量在栈中。
以上三个变量,局部变量永远不会存在线程安全问题,因为局部变量不共享数据(一个线程一个栈)。堆和方法区只有1个,都是多线程共享的,所以存在线程安全问题。

解决线程安全问题
用排队执行解决线程安全问题。这种机制被称为:线程同步。实际上就是线程不能并发了,线程必须排队执行。
线程同步就是线程排队了,线程排队了就会牺牲一部分效率。数据安全第一位,数据安全了,再去谈效率。

模拟两个线程对同一个账户取款
Account类

private String actno; //账号
private double balance; //余额
//省略构造方法,省略getter和setter方法
...
//取款方法
public void withdraw(double money) {
	double before = this.getBalance(); //取款前的余额
	double after = before - money; //取款后的余额
	//如果线程t1执行到这,但还没执行下面这行代码,此时t2线程也进入withdraw方法,就出问题了。
	//模拟网络延迟,一定会出问题
	try{
		Thread.sleep(1000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	
	this.setBalance(after); //更新余额
}

AccountThread

public class AccountThread extends Thread {
	//两个线程必须共享同一个账户对象
	private Account act;
	public AccountThread(Account act) {
		this.act = act;
	}
	public void run() {
		double money = 5000;
		act.withdraw(money);
		sout(Thread.currentThread().getName() + "对" + act.getActno() + "取款" + money + "成功,余额" + act.getBalance());
	}
}

主方法,创建两个对象

public class Test{
	public static void main(String[] args) {
		//创建一个账户对象
		Account act = new Account("act-001", 10000);
		//创建两个线程
		Thread t1 = new AccountThread(act);
		Thread t2 = new AccountThread(act);
		t1.setName("t1");
		t2.setName("t2");
		
		t1.start();
		t2.start();
	}
}

结果:

t1对act-001取款5000.0成功,余额5000.0
t2对act-001取款5000.0成功,余额5000.0

同步代码块synchronized

将上面的例子修改成线程安全的,加synchronized同步代码块。
以下几行代码必须是线程排队的,不能并发。一个线程执行结束,另一个线程才能进来。

public void withdraw(double money) {
	synchronized (this){
		double before = this.getBalance();
		double after = before - money;
		try{
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		this.setBalance(after);
	}
}

这里至关重要的是,synchronized括号中传递的数据,这个数据必须是多线程共享的数据,才能达到多线程排队。
括号中写什么,是要看你想让哪些线程同步。

假设t1、t2、t3、t4、t5,有5个线程,你想让t1、t2、t3排队,那括号中要写t1、t2、t3共享的对象,而这个对象对于t4、t5来说不是共享的。

在java语言中,任何一个对象都有“一把锁”,这把锁其实是一个标记。
同步代码块执行原理

  • 假设t1和t2线程并发,开始执行以下代码的时候,会有先有后。
  • 假设t1先执行了,遇到了synchronized,这个时候自动找“括号中共享对象”的对象锁,找到之后,并占有这把锁,然后执行同步代码块中的程序,在程序执行过程中一直都是占有这把锁的。直到同步代码块结束,这把锁才会释放。
  • 如果t1已经占有这把锁,此时t2也遇到synchronized关键字,也会去占有括号中共享对象的这把锁,结果这把锁已经被t1占有,t2只能在同步代码块外面等待t1的结束。直到t1把同步代码块执行结束了,t1归还这把锁,此时t2终于等到了这把锁,然后t2才能占有这把锁,进入同步代码块执行程序。

    进入锁池,可以理解成一种阻塞状态。

对共享数据的理解
this指的是对象本身,这个程序中是指Account对象。

synchronized (this){
	...
}

创建的两个线程,使用的同一个对象,那么就算是共享数据。

Account act = new Account("act-001", 10000);
Thread t1 = new AccountThread(act);
Thread t2 = new AccountThread(act);

Account act2 = new Account("act-002", 10000);
Thread t3 = new AccountThread(act2);

而下面又创建了一个Account对象,这个对象创建出来的线程,跟上面数据是不共享的,这个Account对象的this是另一个。
总之,想清楚synchronized括号中的对象,是不是想要共享的。

写成abc字符串,创建出来的线程就都是同步的了。

synchronized ("abc"){
	...
}

在实例方法上使用synchronized
synchronized出现在实例方法上,一定锁的是this(共享对象是this),不能是其它对象了。
缺点:不灵活;整个方法体都需要同步,可能会无故扩大同步的范围,导致程序的执行效率降低。
优点:代码节俭。
如果共享的对象就是this,并且需要同步的代码块时整个方法体,建议使用这种方式。

public synchronized  void withdraw(double money) {
	double before = this.getBalance();
	double after = before - money;
	try{
		Thread.sleep(1000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	this.setBalance(after);
}

synchronized的第三种写法
在静态方法上使用synchronized,表示类锁类锁永远只有1把
对象锁:1个对象1把锁,100个对象100把锁。
类锁:100个对象,也可能只是1把类锁。

public static void main(String[] args) {
	MyClass mc1 = new MyClass();
	MyClass mc2 = new MyClass();
	Thread t1 = new MyThread(mc1);
	Thread t2 = new MyThread(mc2);
	...
}

class MyClass {
	public synchronized static void doSome() {
		sout("doSome begin");
		try{
			Thread.sleep(1000*10);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		sout("doSome over");
	}
	public synchronized static void doOther() {
		sout("doOther begin");
		sout("doOther end");
	}
}

t2需要等待t1执行完才执行,因为类锁永远只有1把,即使创建了两个对象,用的也是同一个锁。

死锁现象

死锁很难调试,不会出异常也不报错。要求会写死锁代码,只有回写了,才会在以后的开发中注意这个事儿。

public class DeadLock {
	public static void main(String[] args) {
		Object o1 = new Object();
		Object o2= new Object();

		Thread t1 = new MyThread(o1,o2);
		Thread t2 = new MyThread(o1,o2);
		t1.start();
		t2.start();
	}
}
class MyThread1 extends Thread {
	Object o1;
	Object o2;
	public MyThread1(Object o1, Object o2) {
		this.o1 = o1;
		this.o2 = o2;
	}
	public void run() {
		synchronized (o1) {
			try{
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (o2) {

			}
		}
	}
}
class MyThread2 extends Thread {
	Object o1;
	Object o2;
	public MyThread2(Object o1, Object o2) {
		this.o1 = o1;
		this.o2 = o2;
	}
	public void run() {
		synchronized (o2) {
			try{
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			synchronized (o1) {

			}
		}
	}
}

所以,synchronized最好不要嵌套使用 ,一不小心,可能就会产生死锁。

在实际开发中,解决线程安全问题
不要一上来就选择线程同步synchronized。

  • 第一种方案:尽量使用局部变量代替“实例变量和静态变量”。
  • 第二种方案:如果必须是实例变量,那么可以考虑创建多个对象,这样变量的内存就不共享了。(1个线程1个对象,100个线程对应100个对象,对象不共享,就没有数据安全问题了)
  • 第三种方案:如果不能使用局部变量,对象也不能创建多个,这个时候就只能选择synchronized了,线程同步机制。

3.7 守护线程和定时器

java语言中线程分为两大类:
用户线程
守护线程(后台线程)

其中具有代表性的就是:垃圾回收线程(守护线程)。

守护线程的特点:
一般守护线程是一个死循环,所有的用户线程只要结束,守护线程自动结束。

注意:主线程main方法是一个用户线程。

例如:每天00:00的时候系统数据自动备份,这个需要使用到定时器,并且我们可以将定时器设置为守护线程,一直在那里看着,每到00:00的时候就备份一次。当所有的用户线程结束了,守护线程自动退出,没有必要进行数据备份了。

实现:在启动线程之前,将线程设置为守护线程。

public static void main(String[] args) {
	Thread t = new BakDataThread();
	t.setName("备份数据的线程");
	t.setDaemon(true); //将主线程设置为守护线程
	t.start();
	...
}

定时器的作用:间隔特定的时间,执行特定的程序。
如:每周要进行银行账户的总账操作;每天要进行数据的备份操作。

在实际开发中,每隔多久执行一段特定的程序,这种需求是很常见的,在java中其实可以采用多种方式实现:

  • sleep方法,设置睡眠时间。这种方式是最原始的定时器。(不好)
  • java.util.Timer,java类库中的定时器。
  • Spring提供的SpringTask框架。目前开发中使用较多。

实现定时器,用Timer对象中的schedule方法

public static void main(String[] args) {
	Timer timer = new Timer();
	//Timer timer = new Timer(true); 加一个参数,表示守护线程的方式
	//timer.schedule(定时任务, 第一次执行时间, 间隔多久执行一次)
	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
	Date firstTime = sdf.parse("2022-03-09 14:50:00");
	timer.schedule(new LogTimerTask(), firstTime, 1000*10);
}

class LogTimerTask extends TimerTask {
	@Override
	public void run() {
		//编写任务
	}
}

3.8 实现线程的第三种方式

实现Callable接口(JDK8新特性)。
这种方式实现的线程可以获取线程的返回值。之前讲解的两种方式无法获取线程返回值,因为run方法返回void。

思考:系统委派一个线程去执行一个任务,该线程执行完之后,可能会有一个执行结果,我们怎么能拿到这个执行结果呢?
使用第三种方式:实现Callable接口。

public static void main(String[] args) throws Exception{
	//创建一个“未来任务类”对象
	//参数非常重要,需要给一个Callable接口实现类对象
	FutureTask task = new FutureTask(new Callable() {
		@Override
		public Object call() throws Exception { //call方法相当于run方法,只不过call方法有返回值
		//线程执行一个任务,执行之后可能会有一个执行结果
			sout("call method begin");
			Thread.sleep(1000*10);
			sout("call method end");
			int a = 100;
			int b = 100;
			return a+b;
		}
	});
	Thread t = new Thread(task);
	t.start();
	//用get方法获取t线程的返回结果
	Object obj = task.get(); //get方法执行会导致“当前线程阻塞”
	//main方法这里的程序要想执行,必须等待get()方法的结束
	//而get方法可能需要很久,因为get方法是为了拿另一个线程的执行结果,需要时间
	sout("线程执行结果:" + obj);
	sout("hello world!");
}

优点:可以获取到线程的执行结果。
缺点:效率比较低,在获取t线程执行结果的时候,当前线程受阻塞,效率较低。

3.9 生产者消费者模型

  • wait和notify方法
    注意:wait和notify方法不是线程对象的方法,是java中任何一个对象都有的方法,因为这两个方法是Object类中自带的。wait和notify不是通过线程对象调用。

    wait方法表示:让正在对象上活动的线程进入等待状态,无期限等待,直到被唤醒为止。
    notify方法表示:对象调用wait方法进入等待状态,直到调用notify方法才唤醒。

    生产者和消费者模型是为了专门解决某个特定需求的。

    wait方法会让正在对象上活动的当前线程进入等待状态,并且释放之前对象占有的锁。
    notify方法只会通知,不会释放占有的锁。

    实现生产者消费者模型:
    仓库采用list集合,假设list集合中只能存储1个元素,1个元素就表示仓库满了。
    如果list集合中元素个数是0,就表示仓库空了。
    保证list集合中永远都是最多存储1个元素。
    做到:生产1个,消费一个。

    public static void main(String[] args) {
    	List list1 = new ArrayList();
    	Thread t1 = new Thread(new Producer(list));
    	Thread t2 = new Thread(new Consumer(list));
    	t1.setName("生产者线程");
    	t2.setName("消费者线程");
    	t1.start();
    	t2.start();
    }
    class Producer implements Runnable {
    	private List list;
    	public Produces(List list) {
    		this.list = list;
    	}
    	@Override
    	public void run() {
    		while(true) {
    			//给仓库对象list加锁
    			synchronized(list) {
    				if(list.size() > 0) {
    					try{
    						//线程进入等待状态,并释放Producer占有的锁
    						list.wait();
    					} catch (InterruptedException e) {
    						e.printStackTrace();
    					}
    				}
    				//程序能执行到这里说明仓库是空的,可以生产
    				Object obj = new Object();
    				list.add(obj);
    				sout(Thread.currentThread().getName() + "--->" + obj);
    				list.notify();
    			}
    		}
    	}
    }
    class Consumer implements Runnable {
    	private List list;
    	public Consumer(List list) {
    		this.list = list;
    	}
    	@Override
    	public void run() {
    		//一直消费
    		while(true) {
    			synchronized(list) {
    				if(list.size() == 0) {
    					try{
    						list.wait();
    					} catch (InterruptedException e) {
    						e.printStackTrace();
    					}
    				}
    				//程序能执行到这里说明仓库有数据,进行消费
    				Object obj = list.remove(0);
    				sout(Thread.currentThread().getName() + "--->" + obj);
    				//唤醒生产者生产
    				list.notify();
    			}
    		}
    	}
    }
    

4. 遗漏的基础

4.1 static关键字

变量分类:
局部变量:在方法体当中声明的变量。
成员变量:在方法体外声明的变量。
成员变量又可分为:实例变量、静态变量

通过static关键字修饰的,都是类相关的,访问时用类名.的方式访问。不需要对象参与即可访问。
没有通过static关键字修饰的,是对象相关的,访问时用对象.的方式访问,需要先new对象。

静态变量在类加载时初始化,不需要new对象,静态变量的空间就开出来了。
静态变量存储在方法区。
静态变量如果没有赋值,默认为null。

什么时候使用static修饰?
1.一个属性,每次实例化都是相同的值,不建议定义为实例变量,浪费内存空间。建议定义成类级别的,用static修饰,在方法区中只保留一份,节省内存开销。
2.当属于同一个类的所有对象出现共享数据时,需要将存储这个共享数据的成员变量用static修饰。

注意:实例的,一定要用引用. 来访问;静态的可以用类名.也可以用对象.来访问。
静态的,使用对象.访问,会转换成类名.去访问。不建议使用对象.访问,因为会给其他程序员造成困惑。

Chinese c1 = null;
sout(c1.country); //如果country是静态变量,可以访问。因为c1会被转换成类名。
//如果是country是实例变量,会出现空指针异常。

通过以上结论,我们可以得出,“空引用”访问“实例”相关的,都会出现空指针异常。

  • 静态代码块
    static静态代码块:类加载时执行,并且只执行一次。

    static {
    	sout("A");
    }
    static {
    	sout("B");
    }
    public static void main(String[] args) {
    	sout("Hello World!");
    }
    static {
    	sout("C");
    }
    

    结果:

    A
    B
    C
    Hello World!

    静态代码块在类加载时执行,并且在main方法执行之前执行。一般按照自上而下的顺序执行。

  • 静态方法不存在方法覆盖
    方法覆盖只是针对于“实例方法”,“静态方法覆盖”没有意义。
    方法覆盖需要和多态机制联合起来使用才有意义。

Animal a = new Cat(); //多态
a.doSome(); //doSome是静态方法,这句代码会被转换成Animal.doSome(),跟子类无关了

4.2 多态

  • 多态概述
    多种形态,多种状态,编译和运行有两个不同的状态。
    编译器叫做静态绑定。
    运行期叫做动态绑定。

    Animal a = new Cat();
    a.move();
    

    编译的时候,编译器发现a的类型是Animal,所以编译器会去Animal类中找move()方法。找到了,绑定,编译通过。但是运行的时候和底层堆内存当中的实际对象有关。真正执行的时候会自动调用“堆内存中真实对象”的相关方法。

  • 向上转型和向下转型的概念
    向上转型:子—>父,又被称为自动类型的转换:Animal a = new Cat();

    向下转型:父—>子,又被称为强制类型转换:Cat c = (Cat) a;
    需要调用或执行子类对象中特有的方法,必须进行向下转型,才可以调用
    风险:容易出现ClassCastException(类型转换异常)。
    所以,要使用instanceof运算符,在程序运行阶段动态的判断某个引用指向的对象是否为某一种类型。

    不管是向上转型还是向下转型,首先他们之间必须有继承关系,这样编译器就不会报错。

  • 多态的实际应用

    public class Pet {
    	//吃的行为,这个方法可以不给具体的实现
    	public void eat() {}
    }
    
    public class Cat extends Pet {
    	public void eat() {
    		sout("猫吃鱼...");
    	}
    }
    
    public class Master {
    	//编译的时候,编译器发现pet是Pet类,会去Pet类中找eat()方法,找到了,编译通过
    	//运行时,调用实际对象对应的eat方法
    	public void feed(Pet pet) {
    		pet.eat();
    	}
    }
    
    public static void main(String[] args) {
    	Master zhangsan = new Master();
    	Cat a = new Cat();
    	zhangsan.feed(a); //这里传递的a就是使用了多态,因为Master类中feed方法的参数是Pet对象,Pet pet = new Cat()
    }
    

    分析:如果以后有新的需求,只需要添加一个新的类,然后再去测试就行,不用修改其他类中的代码。
    在实际开发中,用户的需求增加了,也就只用增加新的功能,不用修改原来的代码。
    软件在扩展新需求过程当中,修改的越少越好。修改的越多,你的系统当前的稳定性就越差,未知的风险就越多。
    这里涉及到一个软件的开发原则:OCP(开闭原则)
    软件开发有七大原则,这是最基本的一条。
    开闭原则:对扩展开放,对修改关闭

4.3 抽象类和接口

  • 抽象类
    类和类之间具有共同特征,将这些共同特征提取出来,形成的就是抽象类。
    抽象类无法实例化,无法创建对象,所以抽象类是用来被子类继承的。
    抽象类也属于引用数据类型。
    abstract和final不能联合使用,这两个关键字是对立的。
    抽象类的子类可以是抽象类。
    抽象类虽然无法实例化,但是抽象类有构造方法,这个构造方法是供子类使用的。

    抽象方法:抽象方法表示没有实现的方法,没有方法体的方法。
    抽象类不一定有抽象方法,抽象方法必须出现在抽象类中。
    一个非抽象类继承抽象类,必须将抽象类中的方法实现了。

  • 接口
    接口是一种“引用数据类型”。
    接口是完全抽象的。
    接口支持多继承。
    接口中只有常量和抽象方法。
    接口中所有的元素都是public修饰的。
    接口中抽象方法的public abstract可以省略。
    接口中常量的public static final可以省略。
    接口中方法不能有方法体。
    一个非抽象类,实现接口的时候,必须将接口中所有方法加以实现。
    一个类可以实现多个接口。
    extends和implement可以共存,extends在前,implement在后。
    使用接口,写代码的时候,可以使用多态。

    抽象类和接口的区别:
    抽象类是半抽象的;接口是完全抽象的。
    抽象类没有构造方法;接口中没有构造方法。
    接口和接口之间支持多继承;类和类之间只能单继承。
    一个类可以同时实现多个接口;一个类只能继承一个抽象类。
    接口中只允许出现常量和抽象方法。
    接口一般都是对“行为”的抽象。

更多推荐

JavaSE重点之集合、IO、多线程