JAVA中Object類方法詳解
一、引言
Object是java所有類的基類,是整個類繼承結構的頂端,也是最抽象的一個類。大家天天都在使用toString()、equals()、hashCode()、waite()、notify()、getClass()等方法,或許都沒有意識到是Object的方法,也沒有去看Object還有哪些方法以及思考為什麼這些方法要放到Object中。本篇就每個方法具體功能、重寫規則以及自己的一些理解。
二、Object方法詳解
Object中含有:registerNatives()、getClass()、hashCode()、equals()、clone()、toString()、notify()、notifyAll()、wait(long)、wait(long,int)、wait()、finalize()共十二個方法。這個順序是按照Object類中定義方法的順序列舉的,下面我也會按照這個順序依次進行講解。
1.1、registerNatives()
public class Object { private static native void registerNatives(); static { registerNatives(); } }
什麼鬼?哈哈哈,我剛看到這方法,一臉懵逼。從名字上理解,這個方法是註冊native方法(本地方法,由JVM實現,底層是C/C++實現的)向誰註冊呢?當然是向JVM,當有程式呼叫到native方法時,JVM才好去找到這些底層的方法進行呼叫。
Object中的native方法,並使用registerNatives()向JVM進行註冊。(這屬於JNI的範疇,9龍暫不瞭解,有興趣的可自行查閱。)
static JNINativeMethod methods[] = { {"hashCode", "()I", (void *)&JVM_IHashCode}, {"wait", "(J)V", (void *)&JVM_MonitorWait}, {"notify", "()V", (void *)&JVM_MonitorNotify}, {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll}, {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone}, };
為什麼要使用靜態方法,還要放到靜態塊中呢?
我們知道了在類初始化的時候,會依次從父類到本類的類變數及類初始化塊中的類變數及方法按照定義順序放到< clinit>方法中,這樣可以保證父類的類變數及方法的初始化一定先於子類。所以當子類呼叫相應native方法,比如計算hashCode時,一定可以保證能夠呼叫到JVM的native方法。
1.2、getClass()
public final native ClassgetClass():這是一個public的方法,我們可以直接通過物件呼叫。
類載入的第一階段類的載入就是將.class檔案載入到記憶體,並生成一個java.lang.Class物件的過程。getClass()方法就是獲取這個物件,這是當前類的物件在執行時類的所有資訊的集合。這個方法是反射三種方式之一。
1.2.1、反射三種方式:
- 物件的getClass();
- 類名.class;
- Class.forName();
class extends ObjectTest { private void privateTest(String str) { System.out.println(str); } public void say(String str) { System.out.println(str); } } public class ObjectTest { public static void main(String[] args) throws Exception { ObjectTest = new (); //獲取物件執行的Class物件 Class<? extends ObjectTest> aClass = .getClass(); System.out.println(aClass); //getDeclaredMethod這個方法可以獲取所有的方法,包括私有方法 Method privateTest = aClass.getDeclaredMethod("privateTest", String.class); //取消java訪問修飾符限制。 privateTest.setAccessible(true); privateTest.invoke(aClass.newInstance(), "private method test"); //getMethod只能獲取public方法 Method say = aClass.getMethod("say", String.class); say.invoke(aClass.newInstance(), "Hello World"); } } //輸出結果: //class test. //private method test //Hello World
反射主要用來獲取執行時的資訊,可以將java這種靜態語言動態化,可以在編寫程式碼時將一個子物件賦值給父類的一個引用,在執行時通過反射可以或許執行時物件的所有資訊,即多型的體現。對於反射知識還是很多的,這裡就不展開講了。
1.3、hashCode()
public native int hashCode();這是一個public的方法,所以子類可以重寫它。這個方法返回當前物件的hashCode值,這個值是一個整數範圍內的(-2^31 ~ 2^31 - 1)數字。
對於hashCode有以下幾點約束
- 在 Java 應用程式執行期間,在對同一物件多次呼叫 hashCode 方法時,必須一致地返回相同的整數,前提是將物件進行 equals 比較時所用的資訊沒有被修改;
- 如果兩個物件 x.equals(y) 方法返回true,則x、y這兩個物件的hashCode必須相等。
- 如果兩個物件x.equals(y) 方法返回false,則x、y這兩個物件的hashCode可以相等也可以不等。但是,為不相等的物件生成不同整數結果可以提高雜湊表的效能。
- 預設的hashCode是將記憶體地址轉換為的hash值,重寫過後就是自定義的計算方式;也可以通過System.identityHashCode(Object)來返回原本的hashCode。
public class HashCodeTest { private int age; private String name; @Override public int hashCode() { Object[] a = Stream.of(age, name).toArray(); int result = 1; for (Object element : a) { result = 31 * result + (element == null ? 0 : element.hashCode()); } return result; } }
推薦使用Objects.hash(Object… values)方法。相信看原始碼的時候,都看到計算hashCode都使用了31作為基礎乘數,為什麼使用31呢?我比較贊同與理解result * 31 = (result<<5) - result。JVM底層可以自動做優化為位運算,效率很高;還有因為31計算的hashCode衝突較少,利於hash桶位的分佈。
1.4、equals()
public boolean equals(Object obj);用於比較當前物件與目標物件是否相等,預設是比較引用是否指向同一物件。為public方法,子類可重寫。
public class Object{ public boolean equals(Object obj) { return (this == obj); } }
為什麼需要重寫equals方法?
因為如果不重寫equals方法,當將自定義物件放到map或者set中時;如果這時兩個物件的hashCode相同,就會呼叫equals方法進行比較,這個時候會呼叫Object中預設的equals方法,而預設的equals方法只是比較了兩個物件的引用是否指向了同一個物件,顯然大多數時候都不會指向,這樣就會將重複物件存入map或者set中。這就破壞了map與set不能儲存重複物件的特性,會造成記憶體溢位。
重寫equals方法的幾條約定:
- 自反性:即x.equals(x)返回true,x不為null;
- 對稱性:即x.equals(y)與y.equals(x)的結果相同,x與y不為null;
- 傳遞性:即x.equals(y)結果為true, y.equals(z)結果為true,則x.equals(z)結果也必須為true;
- 一致性:即x.equals(y)返回true或false,在未更改equals方法使用的引數條件下,多次呼叫返回的結果也必須一致。x與y不為null。
- 如果x不為null, x.equals(null)返回false。
我們根據上述規則來重寫equals方法。
public class EqualsTest{ private int age; private String name; //省略get、set、建構函式等 @Override public boolean equals(Object o) { //先判斷是否為同一物件 if (this == o) { return true; } //再判斷目標物件是否是當前類及子類的例項物件 //注意:instanceof包括了判斷為null的情況,如果o為null,則返回false if (!(o instanceof )) { return false; } that = () o; return age == that.age && Objects.equals(name, that.name); } public static void main(String[] args) throws Exception { EqualsTest1 equalsTest1 = new EqualsTest1(23, "9龍"); EqualsTest1 equalsTest12 = new EqualsTest1(23, "9龍"); EqualsTest1 equalsTest13 = new EqualsTest1(23, "9龍"); System.out.println("-----------自反性----------"); System.out.println(equalsTest1.equals(equalsTest1)); System.out.println("-----------對稱性----------"); System.out.println(equalsTest12.equals(equalsTest1)); System.out.println(equalsTest1.equals(equalsTest12)); System.out.println("-----------傳遞性----------"); System.out.println(equalsTest1.equals(equalsTest12)); System.out.println(equalsTest12.equals(equalsTest13)); System.out.println(equalsTest1.equals(equalsTest13)); System.out.println("-----------一致性----------"); System.out.println(equalsTest1.equals(equalsTest12)); System.out.println(equalsTest1.equals(equalsTest12)); System.out.println("-----目標物件為null情況----"); System.out.println(equalsTest1.equals(null)); } } //輸出結果 //-----------自反性---------- //true //-----------對稱性---------- //true //true //-----------傳遞性---------- //true //true //true //-----------一致性---------- //true //true //-----目標物件為null情況---- //false
從以上輸出結果驗證了我們的重寫規定是正確的。
注意:instanceof 關鍵字已經幫我們做了目標物件為null返回false,我們就不用再去顯示判斷了。
建議equals及hashCode兩個方法,需要重寫時,兩個都要重寫,一般都是將自定義物件放至Set中,或者Map中的key時,需要重寫這兩個方法。
1.4、clone()
protected native Object clone() throws CloneNotSupportedException;
此方法返回當前物件的一個副本。
這是一個protected方法,提供給子類重寫。但需要實現Cloneable介面,這是一個標記介面,如果沒有實現,當呼叫object.clone()方法,會丟擲CloneNotSupportedException。
public class CloneTest implements Cloneable { private int age; private String name; //省略get、set、建構函式等 @Override protected CloneTest clone() throws CloneNotSupportedException { return (CloneTest) super.clone(); } public static void main(String[] args) throws CloneNotSupportedException { CloneTest cloneTest = new CloneTest(23, "9龍"); CloneTest clone = cloneTest.clone(); System.out.println(clone == cloneTest); System.out.println(cloneTest.getAge()==clone.getAge()); System.out.println(cloneTest.getName()==clone.getName()); } } //輸出結果 //false //true //true
從輸出我們看見,clone的物件是一個新的物件;但原物件與clone物件的String型別的name卻是同一個引用,這表明,super.clone方法對成員變數如果是引用型別,進行是淺拷貝。
那什麼是淺拷貝?對應的深拷貝?
淺拷貝:拷貝的是引用。
深拷貝:新開闢記憶體空間,進行值拷貝。
那如果我們要進行深拷貝怎麼辦呢?看下面的例子。
class Person implements Cloneable{ private int age; private String name; //省略get、set、建構函式等 @Override protected Person clone() throws CloneNotSupportedException { Person person = (Person) super.clone(); //name通過new開闢記憶體空間 person.name = new String(name); return person; } } public class CloneTest implements Cloneable { private int age; private String name; //增加了person成員變數 private Person person; //省略get、set、建構函式等 @Override protected CloneTest clone() throws CloneNotSupportedException { CloneTest clone = (CloneTest) super.clone(); clone.person = person.clone(); return clone; } public static void main(String[] args) throws CloneNotSupportedException { CloneTest cloneTest = new CloneTest(23, "9龍"); Person person = new Person(22, "路飛"); cloneTest.setPerson(person); CloneTest clone = cloneTest.clone(); System.out.println(clone == cloneTest); System.out.println(cloneTest.getAge() == clone.getAge()); System.out.println(cloneTest.getName() == clone.getName()); Person clonePerson = clone.getPerson(); System.out.println(person == clonePerson); System.out.println(person.getName() == clonePerson.getName()); } } //輸出結果 //false //true //true //false //false
可以看到,即使成員變數是引用型別,我們也實現了深拷貝。如果成員變數是引用型別,想實現深拷貝,則成員變數也要實現Cloneable介面,重寫clone方法。
1.5、toString()
public String toString();這是一個public方法,子類可重寫,建議所有子類都重寫toString方法,預設的toString方法,只是將當前類的全限定性類名+@+十六進位制的hashCode值。
public class Object{ public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); } }
我們思考一下為什麼需要toString方法?
我這麼理解的,返回當前物件的字串表示,可以將其列印方便檢視物件的資訊,方便記錄日誌資訊提供除錯。
我們可以選擇需要表示的重要資訊重寫到toString方法中。為什麼Object的toString方法只記錄類名跟記憶體地址呢?因為Object沒有其他資訊了,哈哈哈。
1.6、wait()/ wait(long)/ waite(long,int)
這三個方法是用來執行緒間通訊用的,作用是阻塞當前執行緒,等待其他執行緒呼叫notify()/notifyAll()方法將其喚醒。這些方法都是public final的,不可被重寫。
注意:
- 此方法只能在當前執行緒獲取到物件的鎖監視器之後才能呼叫,否則會丟擲IllegalMonitorStateException異常。
- 呼叫wait方法,執行緒會將鎖監視器進行釋放;而Thread.sleep,Thread.yield()並不會釋放鎖。
- wait方法會一直阻塞,直到其他執行緒呼叫當前物件的notify()/notifyAll()方法將其喚醒;而wait(long)是等待給定超時時間內(單位毫秒),如果還沒有呼叫notify()/nofiyAll()會自動喚醒;waite(long,int)如果第二個引數大於0並且小於999999,則第一個引數+1作為超時時間;
public final void wait() throws InterruptedException { wait(0); } public final native void wait(long timeout) throws InterruptedException; 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); }
1.7、notify()/notifyAll()
前面說了,如果當前執行緒獲得了當前物件鎖,呼叫wait方法,將鎖釋放並阻塞;這時另一個執行緒獲取到了此物件鎖,並呼叫此物件的notify()/notifyAll()方法將之前的執行緒喚醒。這些方法都是public final的,不可被重寫。
- public final native void notify();隨機喚醒之前在當前物件上呼叫wait方法的一個執行緒
- public final native void notifyAll();喚醒所有之前在當前物件上呼叫wait方法的執行緒
下面我們使用wait()、notify()展示執行緒間通訊。假設9龍有一個賬戶,只要9龍一發工資,就被女朋友給取走了。
//賬戶 public class Account { private String accountNo; private double balance; private boolean flag = false; public Account() { } public Account(String accountNo, double balance) { this.accountNo = accountNo; this.balance = balance; } /** * 取錢方法 * * @param drawAmount 取款金額 */ public synchronized void draw(double drawAmount) { try { if (!flag) { //如果flag為false,表明賬戶還沒有存入錢,取錢方法阻塞 wait(); } else { //執行取錢操作 System.out.println(Thread.currentThread().getName() + " 取錢" + drawAmount); balance -= drawAmount; //標識賬戶已沒錢 flag = false; //喚醒其他執行緒 notify(); } } catch (InterruptedException e) { e.printStackTrace(); } } public synchronized void deposit(double depositAmount) { try { if (flag) { //如果flag為true,表明賬戶已經存入錢,取錢方法阻塞 wait(); } else { //存錢操作 System.out.println(Thread.currentThread().getName() + " 存錢" + depositAmount); balance += depositAmount; //標識賬戶已存入錢 flag = true; //喚醒其他執行緒 notify(); } } catch (InterruptedException e) { e.printStackTrace(); } } } //取錢者 public class DrawThread extends Thread { private Account account; private double drawAmount; public DrawThread(String name, Account account, double drawAmount) { super(name); this.account = account; this.drawAmount = drawAmount; } @Override public void run() { //迴圈6次取錢 for (int i = 0; i < 6; i++) { account.draw(drawAmount); } } } //存錢者 public class DepositThread extends Thread { private Account account; private double depositAmount; public DepositThread(String name, Account account, double depositAmount) { super(name); this.account = account; this.depositAmount = depositAmount; } @Override public void run() { //迴圈6次存錢操作 for (int i = 0; i < 6; i++) { account.deposit(depositAmount); } } } //測試 public class DrawTest { public static void main(String[] args) { Account brady = new Account("9龍", 0); new DrawThread("女票", brady, 10).start(); new DepositThread("公司", brady, 10).start(); } } //輸出結果 //公司 存錢10.0 //女票 取錢10.0 //公司 存錢10.0 //女票 取錢10.0 //公司 存錢10.0 //女票 取錢10.0
例子中我們通過一個boolean變數來判斷賬戶是否有錢,當取錢執行緒來判斷如果賬戶沒錢,就會呼叫wait方法將此執行緒進行阻塞;這時候存錢執行緒判斷到賬戶沒錢, 就會將錢存入賬戶,並且呼叫notify()方法通知被阻塞的執行緒,並更改標誌;取錢執行緒收到通知後,再次獲取到cpu的排程就可以進行取錢。反覆更改標誌,通過呼叫wait與notify()進行執行緒間通訊。實際中我們會時候生產者消費者佇列會更簡單。
注意:呼叫notify()後,阻塞執行緒被喚醒,可以參與鎖的競爭,但可能呼叫notify()方法的執行緒還要繼續做其他事,鎖並未釋放,所以我們看到的結果是,無論notify()是在方法一開始呼叫,還是最後呼叫,阻塞執行緒都要等待當前執行緒結束才能開始。
為什麼wait()/notify()方法要放到Object中呢?
因為每個物件都可以成為鎖監視器物件,所以放到Object中,可以直接使用。
1.8、finalize()
protected void finalize() throws Throwable ;
此方法是在垃圾回收之前,JVM會呼叫此方法來清理資源。此方法可能會將物件重新置為可達狀態,導致JVM無法進行垃圾回收。
我們知道java相對於C++很大的優勢是程式設計師不用手動管理記憶體,記憶體由jvm管理;如果我們的引用物件在堆中沒有引用指向他們時,當記憶體不足時,JVM會自動將這些物件進行回收釋放記憶體,這就是我們常說的垃圾回收。但垃圾回收沒有講述的這麼簡單。
finalize()方法具有如下4個特點:
- 永遠不要主動呼叫某個物件的finalize()方法,該方法由垃圾回收機制自己呼叫;
- finalize()何時被呼叫,是否被呼叫具有不確定性;
- 當JVM執行可恢復物件的finalize()可能會將此物件重新變為可達狀態;
- 當JVM執行finalize()方法時出現異常,垃圾回收機制不會報告異常,程式繼續執行。
public class FinalizeTest { private static FinalizeTest ft = null; public void info(){ System.out.println("測試資源清理得finalize方法"); } public static void main(String[] args) { //建立FinalizeTest物件立即進入可恢復狀態 new FinalizeTest(); //通知系統進行垃圾回收 System.gc(); //強制回收機制呼叫可恢復物件的finalize()方法 // Runtime.getRuntime().runFinalization(); System.runFinalization(); ft.info(); } @Override public void finalize(){ //讓ft引用到試圖回收的可恢復物件,即可恢復物件重新變成可達 ft = this; throw new RuntimeException("出異常了,你管不管啊"); } } //輸出結果 //測試資源清理得finalize方法
我們看到,finalize()方法將可恢復物件置為了可達物件,並且在finalize中丟擲異常,都沒有任何資訊,被忽略了。
1.8.1、物件在記憶體中的狀態
物件在記憶體中存在三種狀態:
- 可達狀態:有引用指向,這種物件為可達狀態;
- 可恢復狀態:失去引用,這種物件稱為可恢復狀態;垃圾回收機制開始回收時,回撥用可恢復狀態物件的finalize()方法(如果此方法讓此物件重新獲得引用,就會變為可達狀態,否則,會變為不可大狀態)。
- 不可達狀態:徹底失去引用,這種狀態稱為不可達狀態,如果垃圾回收機制這時開始回收,就會將這種狀態的物件回收掉。
1.8.2、垃圾回收機制
- 垃圾回收機制只負責回收堆記憶體種的物件,不會回收任何物理資源(例如資料庫連線、網路IO等資源);
- 程式無法精確控制垃圾回收的執行,垃圾回收只會在合適的時候進行。當物件為不可達狀態時,系統會在合適的時候回收它的記憶體。
- 在垃圾回收機制回收任何物件之前,總會先呼叫它的finalize()方法,該方法可能會將物件置為可達狀態,導致垃圾回收機制取消回收。
1.8.3、強制垃圾回收
上面我們已經說了,當物件失去引用時,會變為可恢復狀態,但垃圾回收機制什麼時候執行,什麼時候呼叫finalize方法無法知道。雖然垃圾回收機制無法精準控制,但java還是提供了方法可以建議JVM進行垃圾回收,至於是否回收,這取決於虛擬機器。但似乎可以看到一些效果。
public class GcTest { public static void main(String[] args){ for(int i=0;i<4;i++){ //沒有引用指向這些物件,所以為可恢復狀態 new GcTest(); //強制JVM進行垃圾回收(這只是建議JVM) System.gc(); //Runtime.getRuntime().gc(); } } @Override public void finalize(){ System.out.println("系統正在清理GcTest資源。。。。"); } } //輸出結果 //系統正在清理GcTest資源。。。。 //系統正在清理GcTest資源。。。。
System.gc(),Runtime.getRuntime().gc()兩個方法作用一樣的,都是建議JVM垃圾回收,但不一定回收,多執行幾次,結果可能都不一致。
三、總結
本篇舉例講解了Objec中的所有方法的作用、以及也是經常再面試中被問到的相關問題總結。